26 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
- Step 1: Room selection and dates (
Create\Step1Controller) - Step 2: Participant details with card-based UI (
Create\Step2Controller) - Step 3: Payment method selection (
Create\Step3Controller) - 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
- 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 3Edit\IndexController- edit flow with validation before API submission
- Shared Logic:
ParticipantCardFlowTrait- Card rendering and form handlingParticipantCardDataService- Card data generation with optional validation state
- Validation Pattern:
- Both controllers use validation-only forms (
BookingCreateStep2Type,BookingEditType) - Standard Symfony form flow:
handleRequest()→isSubmitted()→isValid() - On invalid: Cards show red border with "unvollständige oder fehlerhafte Daten" badge
- Validation state determined via
ParticipantCardDataService::getAllCardsDataWithValidation() - On valid: Proceed to next step (create) or submit to API (edit)
- Both controllers use validation-only forms (
Key Architectural Layers
BusProNet Integration (src/BusProNet/)
ApiClient- XML API communicationXmlParser/- Response parsers (travels, hotels, bookings)XmlLoader/- Data loaders with cachingDataProcessor/- Transform API data to DTOs
Form System (src/Form/)
- DTOs:
BookingCreateDto,ParticipantDto,ParticipantEditDto(session-stored)ParticipantEditDtowrapper enables cross-participant validation (e.g., email uniqueness)- Wrapper contains participant being edited + full booking context for validation
- Field Handlers: 15+ specialized handlers in
src/Form/Service/- Registered via service tags with dependency resolution
- Process in dependency order via
ParticipantFieldHandlerRegistry ParticipantDateOfBirthFieldHandlerhandles array input from BirthdayType widget
- 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
- Email Uniqueness Validation: Adult participants (16+) must have unique email addresses
- Children (under 16) exempt from validation, can share emails with adults
- Implemented via
ParticipantEditDto::validateEmailUniqueness()callback validator - Case-insensitive comparison with whitespace normalization
- Validation groups:
booking_create,booking_edit
- Date of Birth Field: Uses
BirthdayTypewith three text inputs (day, month, year)- Widget:
widget: 'text'renders separate text fields instead of dropdowns - Input format:
input: 'datetime_immutable'for immutable date handling - Custom theme:
birthday_widgetblock intemplates/forms.html.twighandles rendering - German format: Day.Month.Year with hardcoded placeholders ("Tag", "Monat", "Jahr")
- Field order explicitly set to
['day', 'month', 'year']for German date convention - HTMX attributes applied to parent container for form refresh on change
- Widget:
Service Layer (src/Service/)
BookingService- Core booking workflowBookingPriceCalculatorService- Comprehensive pricing calculationsgetPricingBreakdown()- Complete pricing breakdown with rooms and services grouped by typecalculateAllParticipantIndividualPrices()- Per-participant total pricingcalculateIndividualParticipantPrice()- 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 modeTravelDataService- API integration and cachingParticipantCardDataService- Card display data with optional validation stategetAllCardsData()- Basic card data without validationgetAllCardsDataWithValidation()- Card data enriched with validation state for error display
InsuranceService- Consolidated insurance operations (eligibility, type filtering, reassignment) with request-scoped cachingRoomAssignmentService- 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-unassignmentdetectRoomCapacityConflict(): Calculates if assignment would exceed capacityresolveRoomCapacityConflict(): 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 selectedParticipantFieldOptionsProvider: 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.
Implementation:
ParticipantDto::$notifications- Array of notification messages (keyed by MD5 hash for deduplication)ParticipantDto::addNotification($type, $message, $id = null)- Add notification with auto-generated or custom ID- Field handlers call
addNotification()when making automatic changes (e.g., insurance reassignment, rental clearing) - Controllers collect notifications via
collectParticipantNotifications()after field processing - Notifications sent to frontend via
HX-Triggerresponse header withshowNotificationsevent - Frontend
toast_controller.jsdisplays notifications using toastify-js library - Notification types:
info,warning,success(mapped to Tailwind color classes)
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-15per operation) - With bulk insurance and deep calculation chains, errors compound to
~1e-13or larger - Example: API returns
1812.7, calculation produces1812.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::$originalFingerprinton 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 <= 0included 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:
- Age-dependent fields (dateOfBirth)
- Price-affecting services (skiPass, rentals, courses, board, transportation, pickup, parking)
- Insurance handler LAST (depends on all price-affecting fields)
- 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:
DirectionMappertranslates 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
- Array input handling:
ParticipantDateOfBirthFieldHandlerprocesses array['day' => X, 'month' => Y, 'year' => Z]from three-field widget - Date normalization: Handler converts array to
DateTimeImmutableusingsprintf()with proper formatting
Bulk Insurance Booking
- Applicant enables bulk → applies to all participants
BulkInsuranceBookingConditionhides dependent participant insurance fields- Uses
InsuranceService::batchAssignInsuranceToParticipants()for price tier matching
Data Flow
Create Flow
- Load/create DTO from session
- Enrich with fresh API availability data
- Auto-assign rooms, preselect mandatory services
- Render cards → user edits participant → field handlers process → save to session
- Validation check before step 3
- Final submission to BPN API
Edit Flow
- Load booking from BPN API on first visit
- Generate fingerprint of initial state for dirty detection
- Store in session with
MODE_EDIT - Apply mutability constraints via
EditFieldStateProvider - Same card-based UI as create flow
- Handle canceled participants (status 'S')
- Display warning banner when unsaved changes detected
- Validation on submission: Form wraps cards, validates all participants before API call
- Submit changes back to BPN API (only if validation passes)
- Staleness warnings after 5 minutes
- Session cleanup: "Zurück" button clears session via
app_booking_edit_cancelaction
Common Development Tasks
Adding a New Field Handler
- Create handler class extending
AbstractParticipantFieldHandler - Implement
shouldProcess(),process(),getDependencies() - Register in
services.yamlwithparticipant.field_handlertag - Add field options to
ParticipantFieldOptionsProvider - Add conditional logic to
CreateFieldStateProviderif needed - Update template with HTMX refresh triggers
Adding a New Conditional Field
- Create condition class implementing
FieldConditionInterface - Register in
CreateFieldStateProvider::registerFieldStateConditions() - 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
Adding Cross-Participant Validation
- Add validation method to
ParticipantEditDto(wrapper DTO) - Use
#[Assert\Callback]attribute with appropriate validation groups - Access booking context via
$this->bookingContextfor cross-participant checks - Access current participant via
$this->participant - Validation errors automatically displayed on participant cards via red border and badge
Writing Tests
- Insurance IDs: Always use strings, not integers (e.g.,
'100'not100) - Room properties: Use
$labelproperty, 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:
InsuranceServiceis stateless with no dependencies (no mocking required) - Insurance mutability: In edit mode, insurances are always readonly (API limitation)
- Notification arrays: Use associative keys (MD5 hashes), not numeric indices - use
reset($notifications)to get first notification
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 handlingStep1Controller.php- Room selection and datesStep2Controller.php- Participant details with card-based UIStep3Controller.php- Payment method selectionStep4Controller.php- Final confirmation with comprehensive pricing display and API submission- Integrates
BookingPriceCalculatorServicefor 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
- Integrates
SuccessController.php- Success page after booking completion
Edit Namespace (src/Controller/Booking/Edit/):
IndexController.php- Edit flow with card-based UI, validation, and session managementindex()- Main edit view with dirty state detectioneditParticipant()- Individual participant form editingrefreshParticipantForm()- HTMX refresh without validationreloadFromApi()- Discard changes and reload from APIcancelEdit()- Clean session exit to bookings list
Root Booking Namespace (src/Controller/Booking/):
IndexController.php- Bookings listDownloadController.php- Booking document downloads
Shared Traits (src/Controller/Booking/Traits/):
ParticipantCardFlowTrait- Card rendering, form creation, summary calculation- Creates
ParticipantEditDtowrapper for email uniqueness validation - Wraps participant form type with booking context for cross-participant validation
- Creates
BookingCreateTrait- Create flow helpersBookingDataTrait- API data fetchingBookingExceptionHandlerTrait- Error handling
Templates
Create Flow:
templates/booking/create/step_1.html.twig- Room selectiontemplates/booking/create/step_2.html.twig- Participant cardstemplates/booking/create/step_3.html.twig- Payment methodtemplates/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 pagetemplates/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- Displays validation state with red border and "unvollständige oder fehlerhafte Daten" badge
- Requires
isValidanderrorMessagesin cardData for validation display
templates/booking/_participant_form.html.twig- Participant edit formtemplates/booking/_summary.html.twig- Pricing summary sidebar
Services
src/Service/BookingService.php- Core workflow and session managementsrc/Service/BookingFingerprintService.php- Dirty state detection via SHA-256 fingerprintingsrc/Service/ParticipantCardDataService.php- Card data generationsrc/Form/Service/ParticipantFieldHandlerRegistry.php- Handler orchestrationsrc/Form/Service/ParticipantFieldOptionsProvider.php- Field configuration with mode-aware availabilitysrc/Form/Service/CreateFieldStateProvider.php- Conditional field statessrc/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 (234 tests, 588 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 (including
ParticipantEditDtowrapper) - Service layer (pricing, insurance matching, room assignment, card data generation)
- Conditional field system
- Cross-participant validation (email uniqueness)
- 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
$labelproperty (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-contentwithinnerHTML, 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 validation display: Uses
ParticipantCardDataService::getAllCardsDataWithValidation()to enrich cards with validation state - Cross-participant validation: Implemented via
ParticipantEditDtowrapper with callback validators - Email uniqueness: Adults (16+) require unique emails; children exempt from validation
- Edit mode service availability: Services with
available <= 0remain 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)
- Notification array structure: Uses associative keys (MD5 hashes) for deduplication, not numeric indices
- 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)