Files
myep/docs/BOOKING_SUBMISSION_IMPLEMENTATION.md
T

771 lines
25 KiB
Markdown

# 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:**
```xml
<versicherungen>
<versicherung idversicherung="20040" anzahl="1" zuordnung="1" />
<versicherung idversicherung="42" anzahl="2" zuordnung="2,3" />
</versicherungen>
```
**Full XML Structure:**
```xml
<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:**
```xml
<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:**
```xml
<ergebnis>
<satz typ="NOTIFICATION" />
<nachricht typ="fehler">Error message here</nachricht>
</ergebnis>
```
## Implementation Details
### 1. Response Models
**File:** `src/BusProNet/Model/BookingResponse.php`
```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
<?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
<?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
<?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:**
```php
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:**
```php
public const TYPE_BOOKING = 'BUCHUNG';
```
**Payment Type Constants (in Constants.php):**
```php
public const PAYMENT_TYPE_ID_TRANSFER = 2;
public const PAYMENT_TYPE_ID_DEBIT = 5;
```
**Methods Added:**
```php
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:
```php
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:**
```php
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:**
```php
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:**
```php
/**
* 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
<?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:**
- [x] Single participant booking - ✅ PASSED
- [x] Multiple participants booking - ✅ PASSED
- [x] All service types selected - ✅ PASSED (transportation, rooms, services, pickups, insurances)
- [x] Insurance selection - ✅ PASSED
- [x] Both payment methods - ✅ TRANSFER TESTED (debit not tested)
- [x] Applicant address mandatory - ✅ PASSED
- [x] Dependent participant address optional - ✅ PASSED
- [x] Email mandatory for all - ✅ PASSED
- [x] Mobile mandatory for applicant - ✅ PASSED
- [x] Room quantity matches step 1 - ✅ PASSED
- [x] Pickup location included - ✅ PASSED
- [x] Two-phase submission - ✅ PASSED (inquiry → booking)
- [x] Session cleared on success - ✅ PASSED
- [x] 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