fix: reconcile assigned family insurance rate on updated participant

This commit is contained in:
Björn Fromme
2026-08-05 15:11:48 +02:00
parent 8a46908856
commit 0b642dcfe4
11 changed files with 723 additions and 8 deletions
@@ -6,6 +6,7 @@ namespace App\BusProNet\DataProcessor;
use App\BusProNet\Constants;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Travel;
use App\BusProNet\Utility\DirectionMapper;
use App\BusProNet\XmlLoader\AgencyLoader;
@@ -362,12 +363,97 @@ class BookingDataProcessor
*/
public function createBookingRequestPayload(BookingDto $bookingDto, string $bookingType): array
{
// Re-validate each participant's insurance against current eligibility (price tier,
// family constraint, etc.) before submission - selections can go stale between the
// moment they were chosen and final submission, since users can freely revisit
// earlier steps and change composition/services without ever re-opening the
// insurance field. Must run before the applicant-to-dependents propagation below,
// so that propagation is based on an already-corrected applicant selection.
$this->reconcileInsuranceEligibility($bookingDto);
// Apply bulk or family insurance if applicable (modifies DTO in place)
$this->applyApplicantInsuranceToParticipants($bookingDto);
return $this->payloadBuilder->buildCreatePayload($bookingDto, $bookingType);
}
/**
* Re-validates every participant's currently selected insurance against present-day
* eligibility (price tier, family constraint, age, travel/booking dates, duration) and
* corrects any selection that's gone stale.
*
* Insurance eligibility is normally re-checked when a participant's own card is
* resubmitted (see ParticipantInsuranceFieldHandler::processField()), but nothing forces
* that to happen again if the user changes composition or services on an earlier step
* after insurance was already chosen, then jumps straight to submission. Left unchecked,
* a stale insurance ID (e.g. a family-tariff price tier that no longer matches the
* current total booking price) would reach the BPN API and be rejected as a mismatch.
*
* Mirrors the reassign-or-clear logic already established in
* ParticipantInsuranceFieldHandler for the resubmission case, applied here across all
* participants right before the payload is built.
*/
private function reconcileInsuranceEligibility(BookingDto $bookingDto): bool
{
$selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel);
$changed = false;
foreach ($bookingDto->getParticipants() as $index => $participant) {
$currentInsurance = $participant->insurance;
if (null === $currentInsurance || $currentInsurance->isNoInsurance()) {
continue;
}
$travelPrice = $this->priceCalculatorService->resolveInsuranceTravelPrice($bookingDto, $index, $currentInsurance);
$eligibleInsurances = $this->insuranceService->getEligibleInsurances($selectableInsurances, $participant, $bookingDto, $travelPrice);
if ($this->isInsuranceInList($currentInsurance, $eligibleInsurances)) {
continue;
}
$reassignedInsurance = $this->insuranceService->reassignInsuranceForPriceChange(
$selectableInsurances,
$currentInsurance,
$participant,
$bookingDto,
$travelPrice
);
if (null !== $reassignedInsurance) {
$participant->addNotification(
'warning',
sprintf('Versicherung vor der Übermittlung automatisch angepasst: %s', $reassignedInsurance->label)
);
} else {
$participant->addNotification(
'warning',
'Die gewählte Versicherung ist für diese Buchung nicht mehr verfügbar und wurde entfernt. Bitte erneut auswählen.'
);
}
$participant->insurance = $reassignedInsurance;
$changed = true;
}
return $changed;
}
/**
* Checks if a specific insurance exists in a list of insurances (by id).
*
* @param array<Insurance> $insuranceList
*/
private function isInsuranceInList(Insurance $targetInsurance, array $insuranceList): bool
{
foreach ($insuranceList as $insurance) {
if ($insurance->id === $targetInsurance->id) {
return true;
}
}
return false;
}
/**
* Applies the applicant's insurance to other participants if their selection requires it.
*
@@ -19,6 +19,7 @@ use App\Service\BookingCreateContextFactory;
use App\Service\BookingPriceCalculator;
use App\Service\BookingPriceMismatchAnalyzer;
use App\Service\BookingSessionManager;
use App\Service\ParticipantFormSupport;
use App\Service\RoomPricingCalculator;
use Psr\Log\LoggerInterface;
use Symfony\Component\Form\FormInterface;
@@ -40,6 +41,7 @@ class Step3Controller extends AbstractBookingCreateController
private readonly BookingPriceCalculator $priceCalculator,
private readonly BookingPriceMismatchAnalyzer $priceMismatchDiagnostics,
private readonly ApiClient $apiClient,
private readonly ParticipantFormSupport $participantFormSupportService,
private readonly LoggerInterface $logger,
) {
}
@@ -76,6 +78,12 @@ class Step3Controller extends AbstractBookingCreateController
// Validate booking data with API by submitting an inquiry booking
$inquiryResponse = $this->apiClient->createBookingInquiry($bookingCreateDto);
// Surface any insurance corrections made during payload building (e.g. a
// stale price tier reassigned/cleared right before submission)
foreach ($this->participantFormSupportService->collectAndClearNotifications($bookingCreateDto) as $notification) {
$this->addFlash($notification['type'], $notification['message']);
}
if ($inquiryResponse instanceof Notification) {
$this->handleApiError(
$this->logger,
@@ -20,6 +20,7 @@ use App\Service\BookingConfigurator;
use App\Service\BookingCreateContextFactory;
use App\Service\BookingSessionManager;
use App\Service\NewsletterManager;
use App\Service\ParticipantFormSupport;
use Psr\Log\LoggerInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
@@ -41,6 +42,7 @@ class Step4Controller extends AbstractBookingCreateController
private readonly ApiClient $apiClient,
private readonly CacheInterface $cache,
private readonly NewsletterManager $doubleOptInService,
private readonly ParticipantFormSupport $participantFormSupportService,
private readonly LoggerInterface $logger,
) {
}
@@ -78,6 +80,12 @@ class Step4Controller extends AbstractBookingCreateController
// Submit final booking (already validated in Step 3)
$bookingResponse = $this->apiClient->createBooking($bookingCreateDto);
// Surface any insurance corrections made during payload building (e.g. a
// stale price tier reassigned/cleared right before submission)
foreach ($this->participantFormSupportService->collectAndClearNotifications($bookingCreateDto) as $notification) {
$this->addFlash($notification['type'], $notification['message']);
}
if ($bookingResponse instanceof Notification) {
$this->handleApiError(
$this->logger,
+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)
{
}
+7 -3
View File
@@ -67,9 +67,9 @@ class ParticipantEditDto
* Validates that insurance is selected when required.
*
* Insurance is required for all participants in create mode, EXCEPT for dependent
* participants when the applicant has enabled bulk insurance booking. In that case,
* the insurance field is hidden and will be automatically assigned by the bulk
* insurance handler.
* participants when the applicant has enabled bulk insurance booking, or when the
* applicant has a family insurance selected. In both cases, the insurance field is
* hidden and coverage is derived from the applicant instead.
*
* In edit submissions, this validation is skipped entirely because insurance
* data is readonly and preserved as-is from the BPN API.
@@ -90,6 +90,10 @@ class ParticipantEditDto
if (true === $applicant?->bulkInsuranceBooking) {
return;
}
if (null !== $applicant?->insurance && true === $applicant->insurance->familyInsurance) {
return;
}
}
if (null === $this->participant->insurance) {
@@ -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) {
@@ -152,6 +166,8 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
$noInsurance->price = 0.0;
$participant->insurance = $noInsurance;
$this->recordFamilyIneligibilityAtSelectionTime($bookingDto, $participantIndex, $noInsurance);
return; // Skip all other processing for "no insurance"
}
@@ -188,10 +204,15 @@ 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;
@@ -260,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.
*
+10
View File
@@ -28,3 +28,13 @@
{% endblock %}
{% endembed %}
{% endif %}
{% if app.session.flashBag.peek('warning')|length > 0 %}
{% embed '_partials/_modal.html.twig' with { 'level': 'warning', 'modal': true } %}
{% block title %}
Hinweis
{% endblock %}
{% block content %}
{% include '_partials/_alert.html.twig' with { level: 'warning', messages: app.flashes('warning') } %}
{% endblock %}
{% endembed %}
{% endif %}
+2 -2
View File
@@ -1,9 +1,9 @@
<div class="fixed inset-0 w-full h-full z-50" {{ stimulus_controller('modal') }} {{ stimulus_action('modal', 'close', 'modal-close@window') }}>
<div class="absolute inset-0 backdrop-blur-sm" {{ stimulus_action('modal', 'close', 'click') }}></div>
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 px-4 w-full max-w-3xl">
<div class="{{ html_classes('relative py-4 rounded-md', { 'bg-red-500': level == 'error', 'bg-green-500': level == 'success', 'bg-primary-light': level == 'info' }) }}">
<div class="{{ html_classes('relative py-4 rounded-md', { 'bg-red-500': level == 'error', 'bg-green-500': level == 'success', 'bg-yellow-500': level == 'warning', 'bg-primary-light': level == 'info' }) }}">
<div class="flex justify-between pb-4 px-4">
<div class="text-2xl font-medium text-white">
<div class="{{ html_classes('text-2xl font-medium', { 'text-gray-800': level == 'warning', 'text-white': level != 'warning' }) }}">
{% block title %}{% endblock %}
</div>
<button type="button"
@@ -850,7 +850,13 @@ class BookingDataProcessorTest extends TestCase
$bookingDto->participants = [$applicant, $dependent];
$insuranceService = $this->createMock(InsuranceManager::class);
$insuranceService->method('getSelectableInsurances')->willReturn([]);
// Both insurances must already look eligible, otherwise the submit-time
// reconciliation step (added for stale-insurance re-validation) would clear
// them before propagation even runs - this test is only about propagation.
$insuranceService->method('getSelectableInsurances')->willReturn([$familyInsurance, $dependentInsurance]);
$insuranceService->method('getEligibleInsurances')->willReturnCallback(
fn (array $insurances) => $insurances
);
$insuranceService->method('batchAssignInsuranceToParticipants')->willReturn([
0 => $familyInsurance,
1 => null,
@@ -858,6 +864,7 @@ class BookingDataProcessorTest extends TestCase
$priceCalculatorService = $this->createMock(BookingPriceCalculator::class);
$priceCalculatorService->method('calculateIndividualParticipantPriceExcludingInsurance')->willReturn(0.0);
$priceCalculatorService->method('resolveInsuranceTravelPrice')->willReturn(0.0);
$payloadBuilder = $this->createMock(BookingPayloadBuilder::class);
$payloadBuilder->method('buildCreatePayload')->willReturn([]);
@@ -925,4 +932,148 @@ class BookingDataProcessorTest extends TestCase
$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');
}
/**
* Regression: an insurance selected earlier can go stale by submission time (e.g. its
* price tier no longer matches the current total, because the user added services on an
* earlier step after choosing insurance and jumped straight to submission without
* revisiting the insurance field). createBookingRequestPayload() must re-validate and
* reassign to the correct tier right before the payload is built, so a stale ID never
* reaches the BPN API.
*/
public function testCreateBookingRequestPayloadReassignsStaleInsuranceToCurrentPriceTier(): void
{
$staleInsurance = new Insurance();
$staleInsurance->id = 'tier-low';
$staleInsurance->familyInsurance = false;
$correctTierInsurance = new Insurance();
$correctTierInsurance->id = 'tier-high';
$correctTierInsurance->familyInsurance = false;
$applicant = $this->createMockParticipantDto(0, 'F');
$applicant->insurance = $staleInsurance;
$bookingDto = new BookingDto($this->createMockTravel(), 1);
$bookingDto->participants = [$applicant];
$insuranceService = $this->createMock(InsuranceManager::class);
$insuranceService->method('getSelectableInsurances')->willReturn([$staleInsurance, $correctTierInsurance]);
// Only the higher tier is eligible at the current (updated) price.
$insuranceService->method('getEligibleInsurances')->willReturn([$correctTierInsurance]);
$insuranceService->method('reassignInsuranceForPriceChange')->willReturn($correctTierInsurance);
$insuranceService->method('batchAssignInsuranceToParticipants')->willReturn([0 => $correctTierInsurance]);
$priceCalculatorService = $this->createMock(BookingPriceCalculator::class);
$priceCalculatorService->method('calculateIndividualParticipantPriceExcludingInsurance')->willReturn(0.0);
$priceCalculatorService->method('resolveInsuranceTravelPrice')->willReturn(2600.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($correctTierInsurance, $applicant->insurance);
$this->assertCount(1, $applicant->notifications);
$this->assertSame('warning', array_values($applicant->notifications)[0]['type']);
}
/**
* Regression: when a stale insurance has no eligible replacement at all (e.g. the total
* price now falls outside every tier of that type), it must be cleared rather than
* silently left in place and submitted anyway.
*/
public function testCreateBookingRequestPayloadClearsStaleInsuranceWithoutEligibleReplacement(): void
{
$staleInsurance = new Insurance();
$staleInsurance->id = 'tier-low';
$staleInsurance->familyInsurance = false;
$applicant = $this->createMockParticipantDto(0, 'F');
$applicant->insurance = $staleInsurance;
$bookingDto = new BookingDto($this->createMockTravel(), 1);
$bookingDto->participants = [$applicant];
$insuranceService = $this->createMock(InsuranceManager::class);
$insuranceService->method('getSelectableInsurances')->willReturn([$staleInsurance]);
$insuranceService->method('getEligibleInsurances')->willReturn([]);
$insuranceService->method('reassignInsuranceForPriceChange')->willReturn(null);
$insuranceService->method('batchAssignInsuranceToParticipants')->willReturn([0 => null]);
$priceCalculatorService = $this->createMock(BookingPriceCalculator::class);
$priceCalculatorService->method('calculateIndividualParticipantPriceExcludingInsurance')->willReturn(0.0);
$priceCalculatorService->method('resolveInsuranceTravelPrice')->willReturn(9999.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->assertNull($applicant->insurance);
$this->assertCount(1, $applicant->notifications);
$this->assertSame('warning', array_values($applicant->notifications)[0]['type']);
}
/**
* Regression: an insurance that's still eligible at submission time must be left
* untouched - the reconciliation step must not fire (or notify) on the common,
* unremarkable case where nothing has gone stale.
*/
public function testCreateBookingRequestPayloadLeavesStillEligibleInsuranceUntouched(): void
{
$insurance = new Insurance();
$insurance->id = 'tier-1';
$insurance->familyInsurance = false;
$applicant = $this->createMockParticipantDto(0, 'F');
$applicant->insurance = $insurance;
$bookingDto = new BookingDto($this->createMockTravel(), 1);
$bookingDto->participants = [$applicant];
$insuranceService = $this->createMock(InsuranceManager::class);
$insuranceService->method('getSelectableInsurances')->willReturn([$insurance]);
$insuranceService->method('getEligibleInsurances')->willReturn([$insurance]);
$insuranceService->method('batchAssignInsuranceToParticipants')->willReturn([0 => $insurance]);
$priceCalculatorService = $this->createMock(BookingPriceCalculator::class);
$priceCalculatorService->method('calculateIndividualParticipantPriceExcludingInsurance')->willReturn(0.0);
$priceCalculatorService->method('resolveInsuranceTravelPrice')->willReturn(500.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($insurance, $applicant->insurance);
$this->assertCount(0, $applicant->notifications);
}
}
+50 -1
View File
@@ -728,6 +728,54 @@ class ParticipantEditDtoTest extends TestCase
$this->assertCount(1, $insuranceViolations);
}
public function testDependentWithoutInsurancePassesWhenApplicantHasFamilyInsurance(): void
{
$applicant = $this->createAdultParticipant('[email protected]');
$applicant->insurance = $this->createMockInsurance(familyInsurance: true);
$dependent = $this->createAdultParticipant('[email protected]');
$dependent->insurance = null;
$bookingDto = $this->createBookingDtoWithParticipants([$applicant, $dependent]);
$wrapper = new ParticipantEditDto(
participant: $bookingDto->participants[1],
bookingContext: $bookingDto,
);
$violations = $this->validator->validate($wrapper, null, ['strict_required']);
$insuranceViolations = array_filter(
iterator_to_array($violations),
fn ($v) => 'participant.insurance' === $v->getPropertyPath()
);
$this->assertCount(0, $insuranceViolations);
}
public function testDependentWithoutInsuranceFailsWhenApplicantHasNonFamilyInsurance(): void
{
$applicant = $this->createAdultParticipant('[email protected]');
$applicant->insurance = $this->createMockInsurance(familyInsurance: false);
$dependent = $this->createAdultParticipant('[email protected]');
$dependent->insurance = null;
$bookingDto = $this->createBookingDtoWithParticipants([$applicant, $dependent]);
$wrapper = new ParticipantEditDto(
participant: $bookingDto->participants[1],
bookingContext: $bookingDto,
);
$violations = $this->validator->validate($wrapper, null, ['strict_required']);
$insuranceViolations = array_filter(
iterator_to_array($violations),
fn ($v) => 'participant.insurance' === $v->getPropertyPath()
);
$this->assertCount(1, $insuranceViolations);
}
private function createBookingDtoWithParticipants(array $participants): BookingDto
{
$travel = new Travel();
@@ -821,12 +869,13 @@ class ParticipantEditDtoTest extends TestCase
return $service;
}
private function createMockInsurance(): Insurance
private function createMockInsurance(bool $familyInsurance = false): Insurance
{
$insurance = new Insurance();
$insurance->id = '1';
$insurance->label = 'Test Insurance';
$insurance->price = 10.0;
$insurance->familyInsurance = $familyInsurance;
return $insurance;
}
@@ -198,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
@@ -249,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();