Files
myep/docs/BOOKING_SUBMISSION_IMPLEMENTATION.md
T

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:

  1. 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
  2. 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 zuordnung attribute
  • 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 ID
  • collectTransportationMappings() - Groups transportation services
  • collectRoomMappings() - Groups room assignments
  • collectPickupMappings() - Groups pickup locations
  • collectInsuranceMappings() - Groups insurance selections
  • addServicesFromMap() - 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) or BookingResponse (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 management
  • ApiClient - API communication
  • BookingPriceCalculatorService - Price calculation
  • LoggerInterface - 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 management
  • ApiClient - API communication
  • LoggerInterface - 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::message to 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:

  1. 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
  2. Inquiry Validation Failure:

    • Submit invalid data
    • Verify inquiry returns error
    • Verify booking NOT called
    • Verify user sees error message
    • Verify session NOT cleared
  3. Booking Commit Failure:

    • Inquiry succeeds but booking fails
    • Verify appropriate error handling
    • Verify session NOT cleared
  4. 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.php
  • src/BusProNet/Model/PriceItem.php
  • src/BusProNet/Model/PaymentTerms.php
  • src/BusProNet/Model/Agency.php
  • src/BusProNet/XmlParser/BookingResponseParser.php
  • src/BusProNet/XmlParser/AgencyParser.php
  • src/BusProNet/XmlLoader/AgencyLoader.php
  • src/Controller/Booking/BookingSuccessController.php
  • templates/booking/success.html.twig
  • tests/BusProNet/XmlParser/AgencyParserTest.php
  • docs/BOOKING_SUBMISSION_IMPLEMENTATION.md (this file)
  • docs/BOOKING_SUBMISSION_STATUS.md
  • docs/REFACTORING_BOOKING_DATA_PROCESSOR.md

Modified Files:

  • src/BusProNet/Constants.php - Added payment type ID constants
  • src/BusProNet/DataProcessor/BookingDataProcessor.php - Added createBookingRequestPayload() with parking and wishes section
  • src/BusProNet/ApiClient.php - Added TYPE_BOOKING, TYPE_AGENCIES, createBookingInquiry(), createBooking(), getAgencies()
  • src/BusProNet/XmlParser/ApiResponseParser.php - Added BUCHUNG and AGENTUREN routing
  • src/Controller/Booking/CreateStep3Controller.php - Added inquiry API call with price validation
  • src/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 parameter
  • src/Service/BookingService.php - Added clearBookingCreateDto(), agency ID parameter in startFreshBooking()
  • src/Form/Model/BookingCreateDto.php - Added agencyId property

Known Limitations

  1. Price Validation:

    • Pricing data is parsed but not automatically validated against calculated prices IMPLEMENTED
    • Exact price match validation implemented in Step 3
  2. Email/Password Fields:

    • Response contains <email> and <pdfpasswort> fields that are not currently parsed
    • Can be added if needed for confirmation emails
  3. Update Flow Refactoring:

    • UPDATE flow still uses different payload structure
    • Future refactoring documented in REFACTORING_BOOKING_DATA_PROCESSOR.md
  4. Contact Information:

    • Phone number (mobile) is mandatory for the applicant only
    • Email is mandatory for all participants

Future Enhancements

  1. Price Validation:

    • Compare $bookingResponse->totalPrice with BookingPriceCalculatorService result IMPLEMENTED
    • Warn if discrepancy detected BLOCKS PROGRESSION
  2. Email Confirmation:

    • Parse email/password fields from response
    • Send custom confirmation email
    • Include PDF password in email
  3. Transaction Logging:

    • Log all inquiry/booking requests with responses
    • Facilitate debugging and audit trail
  4. Retry Logic:

    • Handle transient API failures
    • Implement exponential backoff
  5. 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:

  1. Check application logs for exception details
  2. Enable API debug mode to dump XML
  3. Verify data hasn't changed between calls
  4. Check BPN API logs in admin panel

Issue: Session cleared prematurely

Symptoms: User redirected to init page

Debugging:

  1. Verify clearBookingCreateDto() only called after successful booking
  2. Check for duplicate form submissions
  3. 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:

  1. Room quantity using participant count → Fixed to use roomSelections[].quantity
  2. Insurance selection reset on dependent participants → Fixed bulk handler clearing logic
  3. Pickup quantity issues → Simplified to use only outbound pickups
  4. Missing contact info for non-applicants → Removed applicant-only restriction
  5. Parking service not included in payload → Added to collectServiceMappings()
  6. License plate and room remarks not submitted → Added wünsche section to participant payload

Result: All critical features working correctly