Files
myep/docs/PROJECT_OVERVIEW.md
T

22 KiB

MyEP Next Booking - Project Overview

Symfony 6.4 travel booking application integrating with Bus Pro Net (BPN) XML API.

Core Architecture

Multi-Step Booking Flow

  1. Step 1: Room selection and dates (Create\Step1Controller)
  2. Step 2: Participant details with card-based UI (Create\Step2Controller)
  3. Step 3: Payment method selection (Create\Step3Controller)
  4. Step 4: Final confirmation with comprehensive pricing breakdown and submission to BPN API (Create\Step4Controller)
    • Displays complete pricing breakdown by category (rooms, services grouped by type)
    • Shows per-participant total pricing in card headers
    • Displays individual service prices inline with each booked service
    • Three-level pricing transparency: aggregate, participant, and service-level
  5. Edit Flow: Similar card-based UI for existing bookings (Edit\IndexController)

Card-Based UI Pattern (Production)

  • Overview: Grid of participant cards with lazy-loaded individual forms
  • Performance: Handles 50+ participants efficiently via HTMX
  • Controllers:
    • Create\Step2Controller - create flow with validation before step 3
    • Edit\IndexController - edit flow with validation before API submission
  • Shared Logic:
    • ParticipantCardFlowTrait - Card rendering and form handling
    • ParticipantValidationTrait - Validation error extraction for card indicators
  • Validation Pattern:
    • Both controllers use validation-only forms (BookingCreateStep2Type, BookingEditType)
    • Standard Symfony form flow: handleRequest()isSubmitted()isValid()
    • On invalid: Extract error indices, display error banner, highlight cards, disable submit button
    • On valid: Proceed to next step (create) or submit to API (edit)

Key Architectural Layers

BusProNet Integration (src/BusProNet/)

  • ApiClient - XML API communication
  • XmlParser/ - Response parsers (travels, hotels, bookings)
  • XmlLoader/ - Data loaders with caching
  • DataProcessor/ - Transform API data to DTOs

Form System (src/Form/)

  • DTOs: BookingCreateDto, ParticipantDto (session-stored)
  • Field Handlers: 15+ specialized handlers in src/Form/Service/
    • Registered via service tags with dependency resolution
    • Process in dependency order via ParticipantFieldHandlerRegistry
  • Conditional Fields: Universal condition system (FieldConditionInterface)
    • Age-based, field-dependent, service-specific conditions
    • Applied via CreateFieldStateProvider / EditFieldStateProvider
  • HTMX Integration: Real-time updates for dynamic fields

Service Layer (src/Service/)

  • BookingService - Core booking workflow
  • BookingPriceCalculatorService - Comprehensive pricing calculations
    • getPricingBreakdown() - Complete pricing breakdown with rooms and services grouped by type
    • calculateAllParticipantIndividualPrices() - Per-participant total pricing
    • calculateIndividualParticipantPrice() - Single participant total (room + all services)
    • calculateServicePricing() - Service aggregation with grouping by subtype
    • Powers sidebar summary and Step 4 confirmation pricing display
  • BookingFingerprintService - Dirty state detection for edit mode
  • TravelDataService - API integration and caching
  • ParticipantCardDataService - Card display data
  • InsuranceService - Consolidated insurance operations (eligibility, type filtering, reassignment) with request-scoped caching
  • RoomAssignmentService - Automatic room assignment with intelligent conflict resolution

Room Reassignment with Conflict Resolution

The room assignment system allows participants to freely select any room from Step 1 selections, with automatic conflict resolution when capacity is exceeded.

Key Features:

  • Flexible Selection: All rooms from Step 1 always appear in participant dropdown, regardless of current capacity
  • Auto-Assignment: When exactly ONE room type selected, all participants auto-assigned and dropdown disabled
  • Conflict Resolution: When participant selects room at capacity, system automatically unassigns minimum participants needed
  • Unassignment Priority: Highest index participants unassigned first (keeps applicant and early participants stable)
  • User Notifications: Unassigned participants receive warning notifications via toast system

Implementation:

  • ParticipantRoomChoiceLoader: Always includes all selected rooms (no capacity filtering)
  • ParticipantAssignedRoomFieldHandler: Detects conflicts and resolves via auto-unassignment
    • detectRoomCapacityConflict(): Calculates if assignment would exceed capacity
    • resolveRoomCapacityConflict(): Unassigns minimum participants (highest index first)
    • Generates notifications for unassigned participants only (not for user-initiated assignment)
  • RoomAssignmentService: Unchanged - still auto-assigns when single room type selected
  • ParticipantFieldOptionsProvider: Unchanged - disables dropdown when single room type (auto-assigned)

Example Scenario:

  • User selects 1x "Doppelzimmer" (capacity 2), 3 participants
  • Participants 0 and 1 auto-assigned to "Doppelzimmer"
  • Participant 2 manually selects "Doppelzimmer" (at capacity)
  • System automatically unassigns Participant 1 (highest index)
  • Participant 2 gets assigned, Participant 1 receives warning notification

Critical Patterns

Field Handler Pattern

// Handlers process in dependency order (topological sort)
// Example: insurance handler depends on ALL price-affecting fields
$this->fieldHandlerRegistry->processFieldsForParticipant(
    $participantData,
    $bookingDto,
    $index
);

Important: Field handlers use "sync pattern" - only sync fields present in original submission to avoid validation errors.

HTMX Block-Based Rendering

// All HTMX swaps target #main-content with innerHTML
// OOB swaps for sidebar: #booking-summary
return $this->htmxOobResponse(
    'booking/_participant_form.html.twig',
    ['participant_form', 'booking_summary'],
    $templateData
);

Service Enrichment Pattern

Services from BPN API may lack complete data (especially prices). Always enrich from travel data:

// BookingDataProcessor::enrichParticipantServicesFromTravel()
// Looks up each service in travel data and replaces with full version

Notification System

Field handlers generate notifications (auto-changes) → collected by controller → sent via HX-Trigger → displayed as toasts.

Floating-Point Precision in Price Calculations

Critical: All monetary comparisons must account for floating-point arithmetic accumulation errors.

The Problem:

  • Prices are parsed from XML as German-formatted strings (e.g., "1.812,70")
  • Multiple price additions (rooms + services + insurances) accumulate tiny precision errors (~1e-15 per operation)
  • With bulk insurance and deep calculation chains, errors compound to ~1e-13 or larger
  • Example: API returns 1812.7, calculation produces 1812.6999999999998

The Solution:

  • Always round monetary values to 2 decimal places before comparison
  • Use round($price, 2) for cent precision (standard for EUR currency)
  • Never use strict equality (=== or !==) on unrounded float prices

Implementation Example (Step3Controller:93-94):

// CORRECT: Round to cent precision before comparison
$apiTotal = round($inquiryResponse->totalPrice ?? 0.0, 2);
$calculatedTotal = round($this->priceCalculator->calculateGrandTotal($bookingCreateDto), 2);

if ($apiTotal !== $calculatedTotal) {
    // Handle mismatch
}

// WRONG: Direct float comparison (will fail due to precision errors)
if ($inquiryResponse->totalPrice !== $this->priceCalculator->calculateGrandTotal($bookingCreateDto)) {
    // This comparison is unreliable!
}

Important Notes:

  • Rounding eliminates precision errors smaller than 1 cent (€0.01)
  • The BPN API returns prices already rounded to cent precision
  • This is the industry-standard approach for financial calculations
  • Only affects comparison logic - does not alter actual price calculation flow

Dirty State Detection (Edit Mode)

Fingerprint-based change detection to warn users about unsaved modifications:

  • BookingFingerprintService generates SHA-256 hash of all mutable booking data
  • Original fingerprint stored in BookingDto::$originalFingerprint on API load
  • isDirty() compares current state with original to detect changes
  • Yellow warning banner displays when changes detected
  • Update button conditionally shown only when dirty
  • Fingerprint persists in session, survives page refreshes
  • Resets on submission, "Änderungen verwerfen", or "Zurück" actions

Unavailable Services Handling:

  • Services with available <= 0 included in edit mode (not filtered out)
  • ParticipantFieldOptionsProvider::shouldMakeServiceReadonly() implements intelligent readonly logic:
    • Create mode: Uses standard availability calculator (filters out unavailable services)
    • Edit mode: Services participants already have remain editable even if now fully booked
    • Edit mode: Unavailable services participant doesn't have are marked readonly
  • Prevents fingerprint false positives when services become fully booked during editing session
  • Ensures service IDs remain consistent in form submissions for accurate dirty detection

Key Service Dependencies

Field Handler Execution Order

Critical for correct pricing and auto-reassignment:

  1. Age-dependent fields (dateOfBirth)
  2. Price-affecting services (skiPass, rentals, courses, board, transportation, pickup, parking)
  3. Insurance handler LAST (depends on all price-affecting fields)
  4. Bulk insurance handler (applicant only)

Insurance System

  • 3-pass parsing: Referenced IDs → Individual insurances → Packages with family detection
  • Auto-reassignment: Maintains insurance type when price tier changes
  • Age constraints: Absolute age (at travel date) vs birth year
  • Hydration: TravelDataService::hydrateInsurancePackageRelationships() rebuilds package relationships after cache deserialization
  • ID Type: Insurance IDs are strings (not integers) - ensure all test fixtures use string IDs
  • Mutability: Insurances are always readonly in edit mode (API limitation) - see InsuranceMutabilityCondition

Transportation Services

  • Unified pickup field: Single field for both directions (BPN API limitation)
  • Conditional visibility: Pickup vs parking fields mutually exclusive
  • Direction mapping: DirectionMapper translates API ↔ internal codes

Important Field Dependencies

Ski Pass → Rentals → Insurance → Body Dimensions

  • Rentals filtered by ski pass duration (exact date matching)
  • Rental insurance only shown when rentals selected
  • Body dimensions only shown when rentals selected
  • All cleared automatically when dependencies removed

Date of Birth → Age-Dependent Services

  • Courses, additional services, board, insurance hidden until DOB provided
  • Age evaluated at travel start date, not current date
  • Dual constraint types: absolute_age, birth_year, mixed

Bulk Insurance Booking

  • Applicant enables bulk → applies to all participants
  • BulkInsuranceBookingCondition hides dependent participant insurance fields
  • Uses InsuranceService::batchAssignInsuranceToParticipants() for price tier matching

Data Flow

Create Flow

  1. Load/create DTO from session
  2. Enrich with fresh API availability data
  3. Auto-assign rooms, preselect mandatory services
  4. Render cards → user edits participant → field handlers process → save to session
  5. Validation check before step 3
  6. Final submission to BPN API

Edit Flow

  1. Load booking from BPN API on first visit
  2. Generate fingerprint of initial state for dirty detection
  3. Store in session with MODE_EDIT
  4. Apply mutability constraints via EditFieldStateProvider
  5. Same card-based UI as create flow
  6. Handle canceled participants (status 'S')
  7. Display warning banner when unsaved changes detected
  8. Validation on submission: Form wraps cards, validates all participants before API call
  9. Submit changes back to BPN API (only if validation passes)
  10. Staleness warnings after 5 minutes
  11. Session cleanup: "Zurück" button clears session via app_booking_edit_cancel action

Common Development Tasks

Adding a New Field Handler

  1. Create handler class extending AbstractParticipantFieldHandler
  2. Implement shouldProcess(), process(), getDependencies()
  3. Register in services.yaml with participant.field_handler tag
  4. Add field options to ParticipantFieldOptionsProvider
  5. Add conditional logic to CreateFieldStateProvider if needed
  6. Update template with HTMX refresh triggers

Adding a New Conditional Field

  1. Create condition class implementing FieldConditionInterface
  2. Register in CreateFieldStateProvider::registerFieldStateConditions()
  3. Use composite conditions for complex logic (AND/OR/NOT)

Debugging Field Handler Issues

  • Check execution order in ParticipantFieldHandlerRegistry (topological sort)
  • Verify shouldProcess() logic for mode awareness
  • Ensure dependencies declared correctly
  • Check sync pattern: only sync fields in original submission

Writing Tests

  • Insurance IDs: Always use strings, not integers (e.g., '100' not 100)
  • Room properties: Use $label property, not $name
  • Participant names: Index 0 expects "Anmelder:in", others expect "Teilnehmer:in N" (1-based)
  • Mock dependencies: Ensure all constructor dependencies have mocks. Note: InsuranceService is stateless with no dependencies (no mocking required)
  • Insurance mutability: In edit mode, insurances are always readonly (API limitation)

File Locations

Key Design Patterns

Service Availability in Edit Mode: Edit mode requires special handling of unavailable services to prevent fingerprint false positives:

// ParticipantFieldOptionsProvider - intelligent availability filtering
$bookingDto->travel->getAdditionalServicesBySubTypes(
    Constants::TOKEN_COURSES,
    BookingDto::MODE_CREATE === $bookingDto->getMode() // Only filter in create mode
)

// shouldMakeServiceReadonly() - context-aware readonly logic
// In edit mode: service readonly ONLY if unavailable AND participant doesn't have it
private function shouldMakeServiceReadonly(Service $service, BookingDto $bookingDto, int $participantIndex, string $fieldName): bool
{
    if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
        return $this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex);
    }

    // Edit mode: allow keeping services participant already has
    if (null !== $service->available && $service->available > 0) {
        return false;
    }

    $participantHasService = match ($fieldName) {
        'courses' => $this->hasServiceById($participant->courses, $service->id),
        'additionalServices' => $this->hasServiceById($participant->additionalServices, $service->id),
        // ... other field types
    };

    return !$participantHasService; // Readonly only if participant doesn't have it
}

Session Lifecycle Management: Edit mode session requires proper cleanup to prevent dirty state persistence:

  • Entry: IndexController::loadFormData() initializes from API with original fingerprint
  • Exit (save): IndexController::index() clears session on successful API update
  • Exit (discard): IndexController::reloadFromApi() clears session and reloads from API
  • Exit (cancel): IndexController::cancelEdit() clears session when user clicks "Zurück"
  • Validation: Dirty state persists across page refreshes until explicit action taken

Controllers

Create Namespace (src/Controller/Booking/Create/):

  • IndexController.php - Booking session initialization and error handling
  • Step1Controller.php - Room selection and dates
  • Step2Controller.php - Participant details with card-based UI
  • Step3Controller.php - Payment method selection
  • Step4Controller.php - Final confirmation with comprehensive pricing display and API submission
    • Integrates BookingPriceCalculatorService for complete pricing breakdown
    • Calculates per-participant individual prices via calculateAllParticipantIndividualPrices()
    • Template displays three-level pricing: aggregate breakdown, per-participant totals, and inline service prices
    • All service prices shown in blue (text-blue-700) with German formatting
    • Zero prices hidden per project standards
  • SuccessController.php - Success page after booking completion

Edit Namespace (src/Controller/Booking/Edit/):

  • IndexController.php - Edit flow with card-based UI, validation, and session management
    • index() - Main edit view with dirty state detection
    • editParticipant() - Individual participant form editing
    • refreshParticipantForm() - HTMX refresh without validation
    • reloadFromApi() - Discard changes and reload from API
    • cancelEdit() - Clean session exit to bookings list

Root Booking Namespace (src/Controller/Booking/):

  • IndexController.php - Bookings list
  • DownloadController.php - Booking document downloads

Shared Traits (src/Controller/Booking/Traits/):

  • ParticipantCardFlowTrait - Card rendering, form creation, summary calculation
  • ParticipantValidationTrait - Validation error extraction for card indicators
  • BookingCreateTrait - Create flow helpers
  • BookingDataTrait - API data fetching
  • BookingExceptionHandlerTrait - Error handling

Templates

Create Flow:

  • templates/booking/create/step_1.html.twig - Room selection
  • templates/booking/create/step_2.html.twig - Participant cards
  • templates/booking/create/step_3.html.twig - Payment method
  • templates/booking/create/step_4.html.twig - Final confirmation with comprehensive pricing display
    • Aggregate pricing breakdown: Rooms and services grouped by type with totals
    • Per-participant cards: Individual total price in header, all personal details and service selections
    • Inline service pricing: Each service shows price in blue (€X,XX format) next to label
    • Payment summary: Selected payment method and bank details (if debit)
    • Confirmation checkbox: Final acceptance before API submission
  • templates/booking/create/success.html.twig - Success page
  • templates/booking/create/error.html.twig - Error page

Edit Flow:

  • templates/booking/edit/index.html.twig - Edit with participant cards

Shared Components:

  • templates/booking/_participant_card.html.twig - Individual participant card
  • templates/booking/_participant_form.html.twig - Participant edit form
  • templates/booking/_summary.html.twig - Pricing summary sidebar

Services

  • src/Service/BookingService.php - Core workflow and session management
  • src/Service/BookingFingerprintService.php - Dirty state detection via SHA-256 fingerprinting
  • src/Service/ParticipantCardDataService.php - Card data generation
  • src/Form/Service/ParticipantFieldHandlerRegistry.php - Handler orchestration
  • src/Form/Service/ParticipantFieldOptionsProvider.php - Field configuration with mode-aware availability
  • src/Form/Service/CreateFieldStateProvider.php - Conditional field states
  • src/Form/Service/EditFieldStateProvider.php - Edit mode mutability constraints

Field Handlers

  • src/Form/Service/Participant*FieldHandler.php (15+ handlers)
  • Transportation, pickup, parking, ski pass, rentals, insurance, bulk insurance, etc.

Testing

./vendor/bin/phpunit                              # All tests (182 tests, 465 assertions)
./vendor/bin/phpunit tests/Service/               # Service layer
./vendor/bin/phpunit tests/BusProNet/             # API integration
./vendor/bin/phpunit tests/Form/                  # Form processing and field handlers
/opt/homebrew/bin/php-cs-fixer fix --rules=@Symfony  # Code style (Symfony ruleset)

Test Coverage Areas:

  • BusProNet data loaders and processors
  • XML parsers (travels, hotels, bookings, insurances)
  • Form DTOs and field handlers
  • Service layer (pricing, insurance matching, room assignment)
  • Conditional field system
  • Utility classes

Development Environment

ddev start                                         # Start DDEV
ddev composer install                              # Install dependencies
ddev exec bin/console cache:clear                  # Clear cache
ddev logs                                          # Read PHP error logs
ddev exec "php -r 'opcache_reset()';"             # Clear opcache after code changes

Important Notes

  • Room prices are per person
  • Room model uses $label property (not $name) - ensure test fixtures use correct property
  • Zero prices display without suffix (e.g., "Vollpension" not "Vollpension (€0,00)")
  • All services sorted by price (cheapest first) via SortByPriceTrait
  • Field sync pattern critical: Only sync fields in original submission to avoid "extra fields" errors
  • Insurance handler requires mode awareness: Skips processing in edit mode (API doesn't return insurance data)
  • Insurance IDs are strings: All insurance IDs must be strings, not integers (type safety)
  • Clear opcache after code changes affecting hydration or serialization
  • HTMX targeting consistency: All swaps target #main-content with innerHTML, sidebar via OOB swap
  • Validation pattern: Both create and edit flows use validation-only forms that wrap card UI for standard Symfony form handling
  • Card error indicators: ParticipantValidationTrait::extractParticipantErrorIndices() parses form errors to highlight invalid participant cards
  • Edit mode service availability: Services with available <= 0 remain visible and editable for participants who already have them (prevents fingerprint false positives)
  • Session cleanup on exit: All exit paths from edit mode (save, discard, cancel) properly clear session to reset dirty state
  • Participant naming convention: Index 0 is "Anmelder:in", others are "Teilnehmer:in N" (1-based, not 0-based)
  • Floating-point price comparisons: ALWAYS use round($price, 2) before comparing monetary values to avoid precision errors (see "Floating-Point Precision in Price Calculations" section)

References

  • Project conventions: ../CLAUDE.md (root level)
  • User preferences: ~/.claude/CLAUDE.md
  • Documentation index: README.md (this directory)