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
@@ -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);
}
}