20 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 handlingParticipantValidationTrait- 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)
- 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(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 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 dataInsuranceService- Consolidated insurance operations (eligibility, type filtering, reassignment) with request-scoped cachingRoomAssignmentService- Automatic room assignment
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-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
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
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)
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 calculationParticipantValidationTrait- Validation error extraction for card indicatorsBookingCreateTrait- 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 cardtemplates/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 (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
$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 error indicators:
ParticipantValidationTrait::extractParticipantErrorIndices()parses form errors to highlight invalid participant cards - 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)
- 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)