25 KiB
Booking Submission Implementation Guide
Overview
This document describes the complete implementation of the two-phase booking submission system for the CREATE booking flow in MyEP Next Booking.
Implementation Date: 2025-10-06 Status: ✅ TESTED SUCCESSFULLY (2025-10-06) Related Files: See "Files Modified/Created" section below
Architecture
Two-Phase Submission Flow
The booking submission uses a two-phase commit pattern for safety and validation:
-
Phase 1: Inquiry (Anfrage) - Step 3
- Executed at the end of payment method selection (Step 3)
- Validates all booking data with BusProNet API
- Returns pricing information for validation
- Compares API total price with calculated price (exact match required)
- No permanent changes made
- Request payload:
buchungsart => 'Anfrage' - Response status:
<buchung>möglich</buchung>indicates valid - Blocks progression to Step 4 if validation fails or prices don't match
-
Phase 2: Booking (Buchung) - Step 4
- Executed when user confirms booking on Step 4
- Creates actual booking in BPN system (already validated in Step 3)
- Returns transaction number (Vorgangsnummer)
- Request payload:
buchungsart => 'Buchung' - Response status:
<buchung>erfolgt</buchung>indicates success - Fast execution (no re-validation needed)
Request Payload Structure
The payload generation follows the participant-centric data structure established in the CREATE flow:
Key Characteristics:
- Services grouped by ID with participant assignments
- 1-based participant indexing (API requirement)
- Comma-separated participant lists in
zuordnungattribute - Includes ALL service types: transportation, rooms, additional services, pickups, insurances, parking
- Participant wishes (room remarks, license plate) included in
<wünsche>section - Agency ID resolution with fallback to default agency (code '0001')
- Price validation: API total must match calculated total exactly (1:1)
Service Grouping Example:
<versicherungen>
<versicherung idversicherung="20040" anzahl="1" zuordnung="1" />
<versicherung idversicherung="42" anzahl="2" zuordnung="2,3" />
</versicherungen>
Full XML Structure:
<xml>
<user>USERNAME</user>
<key>HASH</key>
<satz typ="BUCHUNG" />
<buchungsart>Anfrage|Buchung</buchungsart>
<status>F</status>
<idreise>12345</idreise>
<anmelder>
<anrede>Herr</anrede>
<vorname>Max</vorname>
<name>Mustermann</name>
<strasse>Musterstraße 1</strasse>
<plz>12345</plz>
<ort>Musterstadt</ort>
<email>[email protected]</email>
<telefon>+49123456789</telefon>
</anmelder>
<teilnehmerliste>
<teilnehmer position="1">
<anrede>Herr</anrede>
<vorname>Max</vorname>
<name>Mustermann</name>
<geburtsdatum>1990-01-01</geburtsdatum>
</teilnehmer>
<!-- Additional participants... -->
</teilnehmerliste>
<beförderungen>
<beförderung idbefoerderung="123" anzahl="1" zuordnung="1" />
</beförderungen>
<unterbringungen>
<unterbringung idunterbringung="456" anzahl="2" zuordnung="1,2" />
</unterbringungen>
<zusatzleistungen>
<zusatzleistung idzusatzleistung="789" anzahl="1" zuordnung="1" />
</zusatzleistungen>
<zustiege>
<zustieg idzustieg="101" anzahl="1" zuordnung="1" />
</zustiege>
<versicherungen>
<versicherung idversicherung="202" anzahl="1" zuordnung="1" />
</versicherungen>
<zahlungsart>
<art>2|5</art> <!-- 2=Überweisung, 5=Lastschrift -->
<kontoinhaber>Max Mustermann</kontoinhaber>
<iban>DE89370400440532013000</iban>
</zahlungsart>
</xml>
Response Structure
Success Response:
<ergebnis>
<satz typ="BUCHUNG" />
<buchung>möglich|erfolgt</buchung>
<vorgang>321530</vorgang>
<preise>
<preis position="1" art="UNT" unterart="DZ" bezeichnung="Doppelzimmer"
datumvon="2025-03-15" datumbis="2025-03-22" anzahl="1" zuordnung="1,2"
einzelpreis="450.00" gesamtpreis="900.00" id="456" />
<!-- More price items... -->
</preise>
<gesamtpreis>1500.00</gesamtpreis>
<zahlungsbedingungen>
<anzahlung betrag="500.00" datum="2025-02-01" />
<restzahlung betrag="1000.00" datum="2025-03-01" />
</zahlungsbedingungen>
</ergebnis>
Error Response:
<ergebnis>
<satz typ="NOTIFICATION" />
<nachricht typ="fehler">Error message here</nachricht>
</ergebnis>
Implementation Details
1. Response Models
File: src/BusProNet/Model/BookingResponse.php
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
final readonly class BookingResponse
{
public function __construct(
public string $status, // 'möglich' or 'erfolgt'
public ?string $transactionNumber = null, // Vorgangsnummer
public array $priceItems = [], // PriceItem[]
public ?float $totalPrice = null, // Gesamtpreis
public ?PaymentTerms $paymentTerms = null, // Payment schedule
) {
}
public function isInquiryValid(): bool
{
return 'möglich' === $this->status;
}
public function isBookingSuccessful(): bool
{
return 'erfolgt' === $this->status;
}
}
File: src/BusProNet/Model/PriceItem.php
Individual price item from response for validation against calculated prices.
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
final readonly class PriceItem
{
public function __construct(
public int $position,
public string $type,
public ?string $subType,
public string $label,
public ?\DateTimeImmutable $dateFrom,
public ?\DateTimeImmutable $dateTo,
public int $quantity,
public string $assignment,
public float $unitPrice,
public float $totalPrice,
public ?int $id,
) {
}
}
File: src/BusProNet/Model/PaymentTerms.php
Payment schedule with deposit and final payment amounts/dates.
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
final readonly class PaymentTerms
{
public function __construct(
public float $depositAmount,
public \DateTimeImmutable $depositDate,
public float $finalPaymentAmount,
public \DateTimeImmutable $finalPaymentDate,
) {
}
}
2. XML Parser
File: src/BusProNet/XmlParser/BookingResponseParser.php
Extends AbstractParser and parses:
- Booking status from
<buchung>node - Transaction number from
<vorgang>node - All price items from
<preise><preis>nodes - Total price from
<gesamtpreis>node - Payment terms from
<zahlungsbedingungen>node
<?php
declare(strict_types=1);
namespace App\BusProNet\XmlParser;
use App\BusProNet\Model\BookingResponse;
use App\BusProNet\Model\PaymentTerms;
use App\BusProNet\Model\PriceItem;
use Symfony\Component\DomCrawler\Crawler;
final class BookingResponseParser extends AbstractParser
{
public function parse(Crawler $node): BookingResponse
{
$status = $this->getTextOrNull($node, 'buchung') ?? '';
$transactionNumber = $this->getTextOrNull($node, 'vorgang');
$priceItems = $this->parsePriceItems($node);
$totalPrice = $this->getFloatOrNull($node, 'gesamtpreis');
$paymentTerms = $this->parsePaymentTerms($node);
return new BookingResponse(
status: $status,
transactionNumber: $transactionNumber,
priceItems: $priceItems,
totalPrice: $totalPrice,
paymentTerms: $paymentTerms
);
}
private function parsePriceItems(Crawler $node): array
{
$priceItems = [];
$node->filterXPath('//preise/preis')->each(function (Crawler $priceNode) use (&$priceItems): void {
$priceItems[] = new PriceItem(
position: (int) $priceNode->attr('position'),
type: $priceNode->attr('art'),
subType: $priceNode->attr('unterart'),
label: $priceNode->attr('bezeichnung'),
dateFrom: $this->parseDate($priceNode->attr('datumvon')),
dateTo: $this->parseDate($priceNode->attr('datumbis')),
quantity: (int) $priceNode->attr('anzahl'),
assignment: $priceNode->attr('zuordnung'),
unitPrice: (float) $priceNode->attr('einzelpreis'),
totalPrice: (float) $priceNode->attr('gesamtpreis'),
id: $priceNode->attr('id') ? (int) $priceNode->attr('id') : null
);
});
return $priceItems;
}
private function parsePaymentTerms(Crawler $node): ?PaymentTerms
{
$termsNode = $node->filterXPath('//zahlungsbedingungen');
if (0 === $termsNode->count()) {
return null;
}
$depositAmount = (float) $termsNode->filterXPath('//anzahlung')->attr('betrag');
$depositDate = $this->parseDate($termsNode->filterXPath('//anzahlung')->attr('datum'));
$finalAmount = (float) $termsNode->filterXPath('//restzahlung')->attr('betrag');
$finalDate = $this->parseDate($termsNode->filterXPath('//restzahlung')->attr('datum'));
if (null === $depositDate || null === $finalDate) {
return null;
}
return new PaymentTerms(
depositAmount: $depositAmount,
depositDate: $depositDate,
finalPaymentAmount: $finalAmount,
finalPaymentDate: $finalDate
);
}
}
3. Payload Generation
File: src/BusProNet/DataProcessor/BookingDataProcessor.php
Main Method:
public function createBookingRequestPayload(
BookingCreateDto $bookingDto,
string $bookingType
): array
Helper Methods:
collectServiceMappings()- Groups services by IDcollectTransportationMappings()- Groups transportation servicescollectRoomMappings()- Groups room assignmentscollectPickupMappings()- Groups pickup locationscollectInsuranceMappings()- Groups insurance selectionsaddServicesFromMap()- Generic XML structure builder
Critical Implementation Details:
- Participant indexing is 1-based (API requirement)
- Services grouped by ID with comma-separated participant assignments
- Insurance included in CREATE flow (unlike UPDATE flow)
- Payment type IDs: 2 for transfer, 5 for debit
4. API Client Methods
File: src/BusProNet/ApiClient.php
Constants Added:
public const TYPE_BOOKING = 'BUCHUNG';
Payment Type Constants (in Constants.php):
public const PAYMENT_TYPE_ID_TRANSFER = 2;
public const PAYMENT_TYPE_ID_DEBIT = 5;
Methods Added:
public function createBookingInquiry(
BookingCreateDto $bookingDto,
bool $debug = false
): Notification|BookingResponse
{
$payload = (new BookingDataProcessor())->createBookingRequestPayload($bookingDto, 'Anfrage');
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_BOOKING),
'satz' => ['@typ' => static::TYPE_BOOKING],
...$payload,
];
return $this->sendRequest(static::TYPE_BOOKING, $data, [], $debug);
}
public function createBooking(
BookingCreateDto $bookingDto,
bool $debug = false
): Notification|BookingResponse
{
$payload = (new BookingDataProcessor())->createBookingRequestPayload($bookingDto, 'Buchung');
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_BOOKING),
'satz' => ['@typ' => static::TYPE_BOOKING],
...$payload,
];
return $this->sendRequest(static::TYPE_BOOKING, $data, [], $debug);
}
Both methods:
- Use
BookingDataProcessor::createBookingRequestPayload() - Return either
Notification(error) orBookingResponse(success) - Support debug mode for XML dumping
5. Response Routing
File: src/BusProNet/XmlParser/ApiResponseParser.php
Added routing for TYPE_BOOKING responses:
case ApiClient::TYPE_BOOKING:
return (new BookingResponseParser())->parse($resultNode);
Error responses still return Notification objects via existing error handling.
6. Controller Logic
Step 3: Validation with Price Check
File: src/Controller/Booking/CreateStep3Controller.php
Dependencies Injected:
BookingService- Session managementApiClient- API communicationBookingPriceCalculatorService- Price calculationLoggerInterface- Error logging
Form Submission Flow:
if ($form->isSubmitted() && $form->isValid()) {
// Call inquiry API to validate booking
$inquiryResponse = $this->apiClient->createBookingInquiry($bookingCreateDto);
if ($inquiryResponse instanceof Notification || !$inquiryResponse->isInquiryValid()) {
// Handle validation failure
}
// Compare API price with calculated price (exact match required)
$apiTotal = $inquiryResponse->totalPrice ?? 0.0;
$calculatedTotal = $this->priceCalculator->calculateGrandTotal($bookingCreateDto);
if ($apiTotal !== $calculatedTotal) {
$this->logger->error('Price mismatch detected - payload incomplete', [
'apiTotal' => $apiTotal,
'calculatedTotal' => $calculatedTotal,
'difference' => abs($apiTotal - $calculatedTotal),
]);
$this->addFlash('error', 'Ein technischer Fehler ist aufgetreten.');
return; // Block progression to Step 4
}
// Proceed to Step 4 (confirmation)
$bookingCreateDto->currentStep = 4;
return $this->redirectToRoute('app_booking_create_step_4');
}
Price Validation Logic:
- Exact match required:
$apiTotal !== $calculatedTotal - No tolerance for rounding differences
- Mismatch indicates missing service in payload
- Logs full context for debugging
Step 4: Final Booking Submission
File: src/Controller/Booking/CreateStep4Controller.php
Dependencies Injected:
BookingService- Session managementApiClient- API communicationLoggerInterface- Error logging
Form Submission Flow:
if ($form->isSubmitted() && $form->isValid()) {
try {
// Submit final booking (already validated in Step 3)
$bookingResponse = $this->apiClient->createBooking($bookingCreateDto);
if ($bookingResponse instanceof Notification) {
$this->addFlash('error', $bookingResponse->message);
return $this->render('booking/create_step_4.html.twig', [...]);
}
if (false === $bookingResponse->isBookingSuccessful()) {
$this->addFlash('error', 'Buchung konnte nicht erstellt werden.');
return $this->render('booking/create_step_4.html.twig', [...]);
}
// Success: Store booking number in flash and clear session
$this->addFlash('booking_number', $bookingResponse->transactionNumber);
$this->bookingService->clearBookingCreateDto($request);
return $this->redirectToRoute('app_booking_success');
} catch (\Exception $e) {
$this->logger->error('Booking creation failed', [...]);
$this->addFlash('error', 'Ein technischer Fehler ist aufgetreten.');
return $this->render('booking/create_step_4.html.twig', [...]);
}
}
Benefits:
- Step 3: Validates early, catches payload errors before confirmation
- Step 4: Fast execution, no validation delay
- User experience: Reduced wait time on final submission
Error Handling:
- API errors: Display
Notification::messageto user - Validation failures: Display generic error message
- Price mismatch: Log detailed context, block with generic error
- Unexpected exceptions: Log full trace and display generic error
7. Service Layer
File: src/Service/BookingService.php
Method Added:
/**
* Clears the booking creation DTO from the session.
*
* This method removes only the booking DTO while preserving other session data.
* Used after successful booking submission to clear the booking flow state.
*/
public function clearBookingCreateDto(Request $request): void
{
$request->getSession()->remove(self::BOOKING_CREATE_KEY);
}
Clears only the booking DTO (not baseline snapshot) after successful submission.
8. Success Page
Controller: src/Controller/Booking/BookingSuccessController.php
<?php
declare(strict_types=1);
namespace App\Controller\Booking;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class BookingSuccessController extends AbstractController
{
#[Route('/bookings/success', name: 'app_booking_success')]
public function success(Request $request): Response
{
$bookingNumber = $request->getSession()->getFlashBag()->get('booking_number')[0] ?? null;
// Redirect to homepage if no booking number (direct access or refresh)
if (null === $bookingNumber) {
return $this->redirectToRoute('app_home');
}
return $this->render('booking/success.html.twig', [
'bookingNumber' => $bookingNumber,
]);
}
}
Implementation Details:
- Booking number passed via flash message (not URL parameter)
- Flash message automatically cleared after first display
- Direct access or page refresh redirects to homepage
- Clean URL:
/bookings/success(no sensitive data in URL) - No persistent browser history with booking numbers
Template: templates/booking/success.html.twig
Displays:
- Success icon (green checkmark)
- Confirmation message
- Booking number (monospace font for easy copying)
- Information about email confirmation
- Link back to homepage
Testing Strategy
Integration Testing
Test Scenarios:
-
Successful Booking:
- Complete all 4 steps
- Submit confirmation form
- Verify inquiry call made
- Verify booking call made
- Verify redirect to success page
- Verify session cleared
-
Inquiry Validation Failure:
- Submit invalid data
- Verify inquiry returns error
- Verify booking NOT called
- Verify user sees error message
- Verify session NOT cleared
-
Booking Commit Failure:
- Inquiry succeeds but booking fails
- Verify appropriate error handling
- Verify session NOT cleared
-
API Error Response:
- API returns Notification
- Verify error message displayed
- Verify session NOT cleared
Sandbox Testing
Prerequisites:
- DDEV environment running
- BPN sandbox credentials configured in
.env.local - Valid travel data available
Test Checklist:
- Single participant booking - ✅ PASSED
- Multiple participants booking - ✅ PASSED
- All service types selected - ✅ PASSED (transportation, rooms, services, pickups, insurances)
- Insurance selection - ✅ PASSED
- Both payment methods - ✅ TRANSFER TESTED (debit not tested)
- Applicant address mandatory - ✅ PASSED
- Dependent participant address optional - ✅ PASSED
- Email mandatory for all - ✅ PASSED
- Mobile mandatory for applicant - ✅ PASSED
- Room quantity matches step 1 - ✅ PASSED
- Pickup location included - ✅ PASSED
- Two-phase submission - ✅ PASSED (inquiry → booking)
- Session cleared on success - ✅ PASSED
- Success page with booking number - ✅ PASSED
Files Modified/Created
Created Files:
src/BusProNet/Model/BookingResponse.phpsrc/BusProNet/Model/PriceItem.phpsrc/BusProNet/Model/PaymentTerms.phpsrc/BusProNet/Model/Agency.phpsrc/BusProNet/XmlParser/BookingResponseParser.phpsrc/BusProNet/XmlParser/AgencyParser.phpsrc/BusProNet/XmlLoader/AgencyLoader.phpsrc/Controller/Booking/BookingSuccessController.phptemplates/booking/success.html.twigtests/BusProNet/XmlParser/AgencyParserTest.phpdocs/BOOKING_SUBMISSION_IMPLEMENTATION.md(this file)docs/BOOKING_SUBMISSION_STATUS.mddocs/REFACTORING_BOOKING_DATA_PROCESSOR.md
Modified Files:
src/BusProNet/Constants.php- Added payment type ID constantssrc/BusProNet/DataProcessor/BookingDataProcessor.php- AddedcreateBookingRequestPayload()with parking and wishes sectionsrc/BusProNet/ApiClient.php- AddedTYPE_BOOKING,TYPE_AGENCIES,createBookingInquiry(),createBooking(),getAgencies()src/BusProNet/XmlParser/ApiResponseParser.php- Added BUCHUNG and AGENTUREN routingsrc/Controller/Booking/CreateStep3Controller.php- Added inquiry API call with price validationsrc/Controller/Booking/CreateStep4Controller.php- Simplified to direct booking submission (validation moved to Step 3)src/Controller/Booking/CreateInitController.php- Added agency resolution with optional query parametersrc/Service/BookingService.php- AddedclearBookingCreateDto(), agency ID parameter instartFreshBooking()src/Form/Model/BookingCreateDto.php- AddedagencyIdproperty
Known Limitations
-
Price Validation:
Pricing data is parsed but not automatically validated against calculated prices✅ IMPLEMENTED- Exact price match validation implemented in Step 3
-
Email/Password Fields:
- Response contains
<email>and<pdfpasswort>fields that are not currently parsed - Can be added if needed for confirmation emails
- Response contains
-
Update Flow Refactoring:
- UPDATE flow still uses different payload structure
- Future refactoring documented in
REFACTORING_BOOKING_DATA_PROCESSOR.md
-
Contact Information:
- Phone number (mobile) is mandatory for the applicant only
- Email is mandatory for all participants
Future Enhancements
-
Price Validation:
Compare✅ IMPLEMENTED$bookingResponse->totalPricewithBookingPriceCalculatorServiceresultWarn if discrepancy detected✅ BLOCKS PROGRESSION
-
Email Confirmation:
- Parse email/password fields from response
- Send custom confirmation email
- Include PDF password in email
-
Transaction Logging:
- Log all inquiry/booking requests with responses
- Facilitate debugging and audit trail
-
Retry Logic:
- Handle transient API failures
- Implement exponential backoff
-
Price Item Validation:
- Compare individual price items with selections
- Detect unexpected charges
Troubleshooting
Issue: Inquiry succeeds but booking fails
Symptoms: User sees error after successful validation
Debugging:
- Check application logs for exception details
- Enable API debug mode to dump XML
- Verify data hasn't changed between calls
- Check BPN API logs in admin panel
Issue: Session cleared prematurely
Symptoms: User redirected to init page
Debugging:
- Verify
clearBookingCreateDto()only called after successful booking - Check for duplicate form submissions
- Verify error handling re-renders without clearing session
References
- BusProNet API Documentation:
docs/Beschreibung XMLAnfrage.pdf - Example Request Payload:
scratch_113.xml - Example Response:
scratch_111.xml(with pricing),scratch_112.xml(minimal) - Payment Step Implementation:
docs/BOOKING_PAYMENT_STEP.md - Refactoring Plan:
REFACTORING_BOOKING_DATA_PROCESSOR.md - Implementation Status:
BOOKING_SUBMISSION_STATUS.md
Implementation Status: ✅ TESTED SUCCESSFULLY Code Quality: ✅ PHP-CS-Fixer validated, syntax checked Test Date: 2025-10-06 Next Step: Improvements and UPDATE flow refactoring (see REFACTORING_BOOKING_DATA_PROCESSOR.md)
Test Results Summary
Test Date: 2025-10-06 Environment: DDEV sandbox with BusProNet API
Successful Test Booking:
- 2 participants with complete data
- All service types: transportation, rooms, additional services, pickups, insurance, parking
- Address validation working (mandatory for applicant, optional for others)
- Contact info validation working (email for all, mobile for applicant)
- Room quantity correctly using step 1 selections
- Two-phase submission successful (inquiry → booking)
- Session cleared after success
- Success page displaying booking number
- Agency ID resolved from optional query parameter with fallback to default (code '0001')
- Participant wishes (room remarks, license plate) included in payload
Bugs Fixed During Testing:
- Room quantity using participant count → Fixed to use roomSelections[].quantity
- Insurance selection reset on dependent participants → Fixed bulk handler clearing logic
- Pickup quantity issues → Simplified to use only outbound pickups
- Missing contact info for non-applicants → Removed applicant-only restriction
- Parking service not included in payload → Added to collectServiceMappings()
- License plate and room remarks not submitted → Added wünsche section to participant payload
Result: ✅ All critical features working correctly