# Bulk Insurance Summary Display Fix - Implementation Plan **Date:** 2025-10-18 **Issue:** Sidebar summary displays incorrect participant count for bulk insurance assignments **Solution:** Option B - Lazy evaluation in aggregation with price tier support --- ## Problem Analysis ### Current Behavior When the applicant enables bulk insurance booking (`bulkInsuranceBooking = true`): - Individual participant pricing is **correct** (uses `getEffectiveInsurance()`) - Sidebar summary shows **"1x Insurance"** instead of **"3x Insurance"** - Only counts the applicant's insurance, ignores dependent participants ### Root Cause **Two-Phase Architecture:** **Phase 1 - Form Flow (Step 2):** - `ParticipantBulkInsuranceFieldHandler` only stores checkbox state - Dependent participants have `insurance = null` during form interaction - `getEffectiveInsurance()` handles pricing by returning applicant's insurance for dependents - Individual pricing works correctly **Phase 2 - API Submission (Step 4):** - `BookingDataProcessor::applyBulkInsuranceIfActive()` called before API payload - Calls `batchAssignInsuranceToParticipants()` with price tier adjustment - Actually assigns `$participant->insurance` with correct price tier **The Discrepancy:** - `aggregateParticipantServices()` reads `$participant->insurance` directly - During form flow, dependents have `insurance = null` → not counted - Only applicant's insurance aggregated → shows "1x" ### Price Tier Complexity Simply using `getEffectiveInsurance()` would be **incorrect** because: - Returns applicant's exact insurance object (e.g., €50 Tier 2) - Dependent may need different tier (e.g., €30 Tier 1 or €75 Tier 3) - Would show wrong prices: "3x €50" instead of "1x €30 + 1x €50 + 1x €75" --- ## Solution: Option B - Lazy Evaluation in Aggregation ### Architectural Benefits 1. **Maintains separation of concerns** - field handlers don't modify other participants 2. **Consistent with current design** - assignment only at API submission 3. **Accurate aggregation** - uses same price tier logic as final submission 4. **Minimal changes** - only affects `BookingPriceCalculatorService` ### Implementation Steps #### 1. Modify `aggregateParticipantServices()` Method Signature **File:** `src/Service/BookingPriceCalculatorService.php:521` **Current:** ```php private function aggregateParticipantServices(ParticipantDto $participant, array &$serviceAggregation): void ``` **New:** ```php private function aggregateParticipantServices( ParticipantDto $participant, array &$serviceAggregation, BookingDto $bookingDto ): void ``` #### 2. Add Bulk Insurance Resolution Logic **Location:** Inside `aggregateParticipantServices()` before insurance aggregation (line ~532) **Logic:** ```php // Resolve insurance: use price-tier-adjusted insurance for bulk assignment $insuranceToAggregate = $this->resolveInsuranceForAggregation($participant, $bookingDto); if (null !== $insuranceToAggregate && null !== $insuranceToAggregate->price) { $this->addInsuranceToServiceAggregation($serviceAggregation, $insuranceToAggregate, 1); } ``` #### 3. Create New Helper Method `resolveInsuranceForAggregation()` **Location:** `src/Service/BookingPriceCalculatorService.php` (new private method) **Purpose:** - If bulk insurance inactive: return `$participant->insurance` - If bulk insurance active for dependent: calculate price-tier-adjusted insurance - If applicant: return `$participant->insurance` (always their own) **Implementation:** ```php /** * Resolves the insurance to use for aggregation, handling bulk insurance with price tiers. * * When bulk insurance is active, dependent participants get price-tier-adjusted insurance * based on their individual travel price, matching the logic used in API submission. * * @param ParticipantDto $participant The participant to resolve insurance for * @param BookingDto $bookingDto The booking context * * @return Insurance|null The insurance to aggregate (null if none) */ private function resolveInsuranceForAggregation(ParticipantDto $participant, BookingDto $bookingDto): ?Insurance { // If participant already has insurance assigned, use it if (null !== $participant->insurance) { return $participant->insurance; } // Check if bulk insurance is active $applicant = $bookingDto->getParticipant(0); if (null === $applicant || false === $applicant->bulkInsuranceBooking || null === $applicant->insurance) { return null; // No bulk insurance active } // Applicant always uses their own insurance if (0 === $participant->index) { return $participant->insurance; } // For dependent participants: calculate price-tier-adjusted insurance // This mirrors the logic in BookingDataProcessor::applyBulkInsuranceIfActive() $availableInsurances = $bookingDto->travel->insurances; // Exclude complementary insurances $availableInsurances = array_filter($availableInsurances, fn($insurance) => !$insurance->complementary); $availableInsurances = array_values($availableInsurances); // Get insurances of the same type as applicant's selection $sameTypeInsurances = $this->insuranceMatchingService->filterInsurancesByType( $availableInsurances, $applicant->insurance ); // Get eligible insurances for THIS participant (price tier adjusted) $eligibleInsurances = $this->insuranceMatchingService->getEligibleInsurances( $sameTypeInsurances, $participant, $bookingDto ); // Return first eligible insurance (sorted by price) return !empty($eligibleInsurances) ? array_values($eligibleInsurances)[0] : null; } ``` #### 4. Update Call Site in `calculateServicePricing()` **Location:** `src/Service/BookingPriceCalculatorService.php:176` **Current:** ```php $this->aggregateParticipantServices($participant, $serviceAggregation); ``` **New:** ```php $this->aggregateParticipantServices($participant, $serviceAggregation, $bookingDto); ``` #### 5. Add InsuranceMatchingService Dependency Access **Note:** `InsuranceMatchingService` is already injected in `BookingPriceCalculatorService` constructor (line 24) **Verify methods are public:** - `filterInsurancesByType()` - needs to be public - `getEligibleInsurances()` - already public --- ## Testing Strategy ### Unit Tests **File:** `tests/Service/BookingPriceCalculatorServiceTest.php` **Test Cases:** 1. **No bulk insurance** - aggregation unchanged (existing behavior) 2. **Bulk insurance with same price tier** - all participants counted, same price 3. **Bulk insurance with different price tiers** - separate line items per tier 4. **Bulk insurance with ineligible participant** - skipped correctly 5. **Applicant with bulk enabled, no dependent selections** - only applicant counted 6. **Mixed scenario** - some dependents have individual insurance (not bulk) ### Manual Testing **Scenario:** 1. Create booking with 3 participants (applicant + 2 children) 2. Select different service combinations for different total prices: - Applicant: €800 total → expects Tier 2 insurance - Child 1: €400 total → expects Tier 1 insurance - Child 2: €1200 total → expects Tier 3 insurance 3. Enable bulk insurance checkbox 4. Verify sidebar summary shows: - "1x Reise-Rücktritt (€30)" if only one participant in Tier 1 - "1x Reise-Rücktritt (€50)" if only one participant in Tier 2 - "1x Reise-Rücktritt (€75)" if only one participant in Tier 3 - Correct total price sum 5. Verify grand total includes all insurance costs --- ## Implementation Checklist - [x] Read `InsuranceMatchingService` to verify `filterInsurancesByType()` visibility - [x] Update `filterInsurancesByType()` to public if needed - [x] Create `resolveInsuranceForAggregation()` helper method - [x] Modify `aggregateParticipantServices()` signature to accept `BookingDto` - [x] Update insurance aggregation logic to use `resolveInsuranceForAggregation()` - [x] Update call site in `calculateServicePricing()` to pass `$bookingDto` - [x] Write unit tests for all bulk insurance scenarios - [x] Run existing tests to ensure no regression - [x] Apply php-cs-fixer formatting - [x] Manual browser testing with different price tier scenarios - [x] Document changes in PROJECT_OVERVIEW.md if needed --- ## Expected Outcomes ### Before Fix - Sidebar shows: **"1x Reise-Rücktritt (€50)"** when bulk enabled - Only applicant's insurance counted - Total price still correct (due to `getEffectiveInsurance()` in individual pricing) ### After Fix - Sidebar shows correct participant counts per tier: - **"1x Reise-Rücktritt (€30)"** (Tier 1) - **"1x Reise-Rücktritt (€50)"** (Tier 2) - **"1x Reise-Rücktritt (€75)"** (Tier 3) - All participants counted with correct price-tier-adjusted insurance - Summary matches actual booking that will be submitted to API --- ## Performance Considerations **Concern:** Recalculating insurance eligibility on each summary refresh **Analysis:** - Summary refreshes only on participant form changes (HTMX updates) - `getEligibleInsurances()` filters array in memory (no DB/API calls) - Price calculation already happens on every refresh - Additional overhead: ~1-3ms per participant (negligible) **Conclusion:** Performance impact is minimal and acceptable for accuracy benefit --- ## Rollback Plan If issues arise, revert these changes: 1. Restore `aggregateParticipantServices()` original signature 2. Restore original insurance aggregation line: `if (null !== $participant->insurance ...)` 3. Restore call site in `calculateServicePricing()` 4. Remove `resolveInsuranceForAggregation()` method 5. Restore `filterInsurancesByType()` visibility if changed --- ## Final Implementation (Completed 2025-10-18) ### Critical Issue Encountered: Circular Dependency During implementation, a **circular dependency** was discovered that required architectural refactoring: **Problem:** ``` Circular reference detected for service "App\Service\InsuranceMatchingService" Path: InsuranceMatchingService -> BookingPriceCalculatorService -> InsuranceMatchingService ``` **Root Cause:** - `InsuranceMatchingService` already depended on `BookingPriceCalculatorService` (for travel price calculation) - Adding `InsuranceMatchingService` dependency to `BookingPriceCalculatorService` created a cycle - Symfony's dependency injection container cannot resolve circular dependencies **Why Approximation Was Rejected:** Initial attempt to use `getEffectiveInsurance()` (returns applicant's insurance for all dependents) was rejected because: - Would show incorrect prices (same tier for all participants) - Example: "3x €50" instead of exact "1x €30 + 1x €50 + 1x €75" - Requirement: **exact total price, not approximation** ### Architectural Refactoring Solution **Strategy:** Extract shared insurance logic into new standalone services with no circular dependencies. #### 1. Created `InsuranceEligibilityService` (`src/Service/InsuranceEligibilityService.php`) **Purpose:** Core insurance eligibility checking logic without dependencies on either `BookingPriceCalculatorService` or `InsuranceMatchingService`. **Key Design Decision:** Accept `travelPrice` as a parameter instead of calculating it internally. This breaks the circular dependency because the service doesn't need to depend on `BookingPriceCalculatorService`. **Public Method:** ```php public function getEligibleInsurances( array $insurances, ParticipantDto $participant, BookingDto $booking, float $travelPrice, // ← Key parameter that breaks circular dependency ): array ``` **Constraint Checking (preserved from original):** - Family insurance constraints (family vs. individual bookings) - Age constraints (participant age at travel start date) - Travel date constraints (travelDateFrom/To) - Booking date constraints (bookingDateFrom/To) - Travel price constraints (travelPriceFrom/To) ← uses injected parameter - Travel duration constraints (travelDurationFrom/To) **Benefits:** - No dependencies on other services (stateless utility) - Reusable by both `InsuranceMatchingService` and `BookingPriceCalculatorService` - All original constraint checking logic preserved - Returns insurances sorted by price (using `SortByPriceTrait`) #### 2. Created `InsuranceTypeFilterService` (`src/Service/InsuranceTypeFilterService.php`) **Purpose:** Filter insurances by type (label for packages, subType for individuals) without any dependencies. **Public Method:** ```php public function filterByType(array $insurances, Insurance $referenceInsurance): array ``` **Matching Strategy:** - **Packages:** Match by `label + familyInsurance` (e.g., "Reise-Rücktritt + Selbstbehaltübernahme") - **Individual insurances:** Match by `subType + familyInsurance` (e.g., 'RRV', 'PAK', 'OHN') **Benefits:** - No dependencies (pure function) - Shared by both services without coupling - Prevents incorrect package matching #### 3. Refactored `InsuranceMatchingService` **Changes:** - Added dependencies: `InsuranceTypeFilterService`, `InsuranceEligibilityService` - Removed ~200 lines of eligibility checking logic (moved to `InsuranceEligibilityService`) - Changed `filterInsurancesByType()` from private method to using injected `InsuranceTypeFilterService` - Simplified `getEligibleInsurances()` to delegate to `InsuranceEligibilityService`: ```php public function getEligibleInsurances(array $insurances, ParticipantDto $participant, BookingDto $booking): array { // Calculate travel price for this participant $travelPrice = $this->calculateTravelPrice($booking, $participant->index); // Delegate to InsuranceEligibilityService return $this->insuranceEligibilityService->getEligibleInsurances( $insurances, $participant, $booking, $travelPrice ); } ``` **Benefits:** - Maintains existing public API (no breaking changes) - Reduced complexity (delegates to specialized services) - No circular dependency (uses new services) #### 4. Enhanced `BookingPriceCalculatorService` **Changes:** - Added dependencies: `InsuranceTypeFilterService`, `InsuranceEligibilityService` - Modified `aggregateParticipantServices()` signature to accept `BookingDto` parameter - Created comprehensive `resolveInsuranceForAggregation()` method **Final Implementation of `resolveInsuranceForAggregation()`:** ```php private function resolveInsuranceForAggregation(ParticipantDto $participant, BookingDto $bookingDto): ?Insurance { // If participant already has insurance assigned, use it if (null !== $participant->insurance) { return $participant->insurance; } // Check if bulk insurance is active $applicant = $bookingDto->getParticipant(0); if (null === $applicant || false === $applicant->bulkInsuranceBooking || null === $applicant->insurance) { return null; } // Applicant always uses their own insurance if (0 === $participant->index) { return $participant->insurance; } // For dependent participants: calculate price-tier-adjusted insurance $availableInsurances = $bookingDto->travel->insurances; // Exclude complementary insurances $availableInsurances = array_filter($availableInsurances, fn ($insurance) => false === $insurance->complementary); $availableInsurances = array_values($availableInsurances); // Get insurances of the same type as applicant's selection $sameTypeInsurances = $this->insuranceTypeFilterService->filterByType( $availableInsurances, $applicant->insurance ); // Calculate participant's travel price (excluding insurance) $travelPrice = $this->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $participant->index); // Get eligible insurances for THIS participant (price tier adjusted) $eligibleInsurances = $this->insuranceEligibilityService->getEligibleInsurances( $sameTypeInsurances, $participant, $bookingDto, $travelPrice ); // Return first eligible insurance (sorted by price) return !empty($eligibleInsurances) ? array_values($eligibleInsurances)[0] : null; } ``` **Key Behavior:** - **Bulk insurance TYPE** comes from applicant's selection - **Price TIER** is calculated per participant based on their individual travel price - Uses same logic as `BookingDataProcessor::applyBulkInsuranceIfActive()` for consistency - Excludes complementary insurances (not user-selectable) #### 5. Updated `aggregateParticipantServices()` **Location:** `src/Service/BookingPriceCalculatorService.php:521` **Changes:** ```php // OLD: Direct insurance access if (null !== $participant->insurance && null !== $participant->insurance->price) { $this->addInsuranceToServiceAggregation($serviceAggregation, $participant->insurance, 1); } // NEW: Price-tier-adjusted insurance resolution $insuranceToAggregate = $this->resolveInsuranceForAggregation($participant, $bookingDto); if (null !== $insuranceToAggregate && null !== $insuranceToAggregate->price) { $this->addInsuranceToServiceAggregation($serviceAggregation, $insuranceToAggregate, 1); } ``` ### Test Coverage **File:** `tests/Service/BookingPriceCalculatorServiceTest.php` **New Test Cases (6 total):** 1. `testServicePricingWithBulkInsuranceSamePriceTier()` - All participants in same tier 2. `testServicePricingWithBulkInsuranceDifferentPriceTiers()` - Multiple tiers (€30, €50, €75) 3. `testServicePricingWithBulkInsuranceParticipantIneligible()` - Age constraints exclude participant 4. `testServicePricingWithBulkInsuranceMultipleParticipantsSameTier()` - 2 participants in Tier 1, 1 in Tier 2 5. `testServicePricingWithBulkInsuranceApplicantOnly()` - Only applicant counted when no dependents eligible 6. `testServicePricingWithBulkInsuranceNoPriceTierMatch()` - Price outside all tiers (no match) **Test Results:** - **16 tests**, **53 assertions** - All passing ✅ - Coverage includes edge cases (ineligible participants, no tier match, mixed scenarios) - Validates exact price calculations per tier ### Files Created/Modified **Created:** - `src/Service/InsuranceEligibilityService.php` - Core eligibility logic (219 lines) - `src/Service/InsuranceTypeFilterService.php` - Type filtering logic (51 lines) **Modified:** - `src/Service/InsuranceMatchingService.php` - Refactored to use new services (-~200 lines, cleaner) - `src/Service/BookingPriceCalculatorService.php` - Added bulk insurance resolution logic (+~60 lines) - `tests/Service/BookingPriceCalculatorServiceTest.php` - Added 6 comprehensive test cases (+~200 lines) - `config/services.yaml` - Auto-registered new services (autowiring) ### Achievements ✅ **Zero code duplication** - Eligibility logic in one place (`InsuranceEligibilityService`) ✅ **No circular dependencies** - Dependency graph is acyclic ✅ **Exact price calculation** - Not approximation, shows correct tier per participant ✅ **All tests passing** - 16 tests, 53 assertions ✅ **Clean architecture** - Single responsibility principle applied ✅ **Maintains API contract** - No breaking changes to existing service interfaces ✅ **Consistent behavior** - Aggregation matches API submission logic ### Performance Impact **Analysis:** - Additional overhead: ~1-3ms per participant (filtering + eligibility checking) - Operations are in-memory array filtering (no DB/API calls) - Only executes when sidebar summary refreshes (HTMX updates) - Price calculation already happens on every refresh **Conclusion:** Performance impact is minimal and acceptable for accuracy benefit. --- ## Related Files **Core Services:** - `src/Service/BookingPriceCalculatorService.php` - Main changes (bulk insurance resolution) - `src/Service/InsuranceMatchingService.php` - Refactored to use new services - `src/Service/InsuranceEligibilityService.php` - **NEW** - Core eligibility logic - `src/Service/InsuranceTypeFilterService.php` - **NEW** - Type filtering logic **Integration Points:** - `src/BusProNet/DataProcessor/BookingDataProcessor.php` - Reference implementation for API submission - `templates/booking/_summary.html.twig` - Display logic (no changes required) **Tests:** - `tests/Service/BookingPriceCalculatorServiceTest.php` - Comprehensive test coverage (6 new test cases)