feat: implement payment step

This commit is contained in:
Björn Fromme
2026-03-16 11:59:10 +01:00
parent 3d9fc18a83
commit 3f7db4e772
12 changed files with 1273 additions and 30 deletions
+726
View File
@@ -0,0 +1,726 @@
# Booking Payment Step Implementation Plan
**Status:** ✅ COMPLETED
**Last Updated:** 2025-01-04
**Goal:** Add payment method selection step (Step 3) to booking creation flow
## Implementation Summary
**Completed Components:**
1. **DTOs Created:**
- `BankAccountDto` - IBAN validation, account holder, bank name, SEPA mandate
- Payment properties added directly to `BookingCreateDto`:
- `paymentMethod` - PAYMENT_METHOD_TRANSFER (default) or PAYMENT_METHOD_DEBIT
- `bankAccount` - BankAccountDto instance (nullable)
- Payment method constants in `Constants.php` (PAYMENT_METHOD_TRANSFER, PAYMENT_METHOD_DEBIT)
2. **Form Type:**
- `BookingCreateStep3Type` - Main step 3 form with payment method and conditional bank account fields
- **Removed** `PaymentType` wrapper (unnecessary with proper event handling)
- Dynamic field management using POST_SET_DATA and PRE_SUBMIT events
- Helper method `addBankAccountField()` to avoid duplication
3. **Controller:**
- `CreateStep3Controller` - Main step 3 action and HTMX refresh endpoint
- Uses `getSummaryVariables()` trait method for booking summary data
- Conditional field rendering handled entirely by form events
4. **Template:**
- `create_step_3.html.twig` - Grid layout matching steps 1 & 2
- Payment method selection with HTMX for dynamic bank account fields
- HTMX attributes on outer wrapper div for proper field replacement
- Booking summary sidebar visible on all steps
5. **Validation:**
- Conditional validation via `validateBankAccount()` callback in `BookingCreateDto`
- Bank account fields only validated when payment method is DEBIT
- IBAN format validation using Symfony's built-in `@Assert\Iban`
- SEPA mandate acceptance required for direct debit
6. **Business Rules:**
- **14-day rule**: Direct debit only available if travel starts ≥14 days from now
- Debit option becomes readonly with tooltip when not available
- Authorization text from existing booking flow integrated into choice label
## Key Implementation Details
### Form Event Pattern
```php
// BookingCreateStep3Type.php - Conditional field rendering
$builder->addEventListener(FormEvents::POST_SET_DATA, function (FormEvent $event): void {
$bookingDto = $event->getData();
if (Constants::PAYMENT_METHOD_DEBIT === $bookingDto->paymentMethod) {
$this->addBankAccountField($event->getForm());
}
});
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event): void {
$data = $event->getData();
$form = $event->getForm();
$paymentMethod = $data['paymentMethod'] ?? null;
if ($form->has('bankAccount')) {
$form->remove('bankAccount');
}
if (Constants::PAYMENT_METHOD_DEBIT === $paymentMethod) {
$this->addBankAccountField($form);
}
});
```
**Key Points:**
- POST_SET_DATA: Adds field when rendering if paymentMethod is DEBIT
- PRE_SUBMIT: Removes existing field first, then adds only if DEBIT selected
- Helper method avoids duplicate field configuration
- No need to manually unset data - Symfony ignores unmapped fields
### Conditional Validation Pattern
```php
// BookingCreateDto.php - Conditional bank account validation
#[Assert\Callback]
public function validateBankAccount(ExecutionContextInterface $context): void
{
if (Constants::PAYMENT_METHOD_DEBIT !== $this->paymentMethod) {
return; // Skip validation for transfer
}
// Validate only when debit is selected
if (null === $this->bankAccount) {
$context->buildViolation('Bitte geben Sie Ihre Bankverbindung an.')
->atPath('bankAccount')
->addViolation();
}
// ... additional field validations
}
```
### 14-Day Availability Rule
```php
private function isDebitAvailable(BookingCreateDto $bookingDto): bool
{
$travelStartDate = CarbonImmutable::instance($bookingDto->travel->dateFrom);
$now = CarbonImmutable::now();
$daysUntilTravel = $now->diffInDays($travelStartDate, false);
return $daysUntilTravel >= 14;
}
```
Applied via `choice_attr` callback to make debit option readonly when unavailable.
### HTMX Pattern for Expanded Forms
For radio buttons (expanded choice types), HTMX attributes must be on a wrapper element:
```twig
<div id="form-payment"
hx-post="{{ path('app_booking_create_step_3_refresh') }}"
hx-trigger="change from:input[type='radio']"
hx-target="#form-payment"
hx-select="#form-payment"
hx-swap="outerHTML">
{{ form_row(form.paymentMethod) }}
{# Conditionally rendered bank account section #}
{% if form.bankAccount is defined %}
...
{% endif %}
</div>
```
**Why this pattern:**
- Wrapper div captures change events from child radio buttons
- Entire section (payment method + bank account fields) gets replaced
- `hx-select` extracts only `#form-payment` from full page response
---
## Executive Summary
Currently, the booking creation flow ends at Step 2 (participant details). We need to add Step 3 to collect payment method information before final submission. Users can choose between:
1. **Direct Transfer (Überweisung)** - Requires bank account details
2. **Direct Debit (Lastschrift)** - Requires bank account details + SEPA mandate
---
## Requirements Analysis
### Payment Methods
#### 1. Direct Transfer (Überweisung)
- User will transfer money manually
- Required fields:
- Payment method selection (radio/select)
- No bank details needed (invoice will show our account for transfer)
#### 2. Direct Debit (Lastschrift)
- Automatic withdrawal from user's account
- Required fields:
- IBAN (validated format)
- Account holder name (text)
- SEPA mandate checkbox (required agreement)
- Optional fields:
- Bank name (text)
### Validation Requirements
**IBAN Validation:**
- Format: Country code (2 letters) + Check digits (2 digits) + BBAN (up to 30 alphanumeric)
- German IBAN: DE + 2 digits + 18 digits (total 22 characters)
- International: Support common EU countries
- Checksum validation (mod 97 algorithm) via Symfony's `@Assert\Iban`
- Display formatted with spaces (DE12 3456 7890 1234 5678 90)
**Account Holder Name:**
- Must match participant/applicant name or be explicitly confirmed
- Min length: 2 characters
- Max length: 70 characters (SEPA standard)
- Allowed: Letters, spaces, hyphens, apostrophes
**Bank Name:**
- Optional but recommended
- Max length: 70 characters
### Data Flow
```
Step 1 (Room Selection)
Step 2 (Participant Details)
Step 3 (Payment Method) ← NEW
Final Submission to API
```
---
## Implementation Plan
### Phase 1: Create Bank Account DTO
#### Task 1.1: Create BankAccountDto
**Status:** ✅ Simplified - BIC omitted
**Files:**
- `src/Form/Model/BankAccountDto.php` (new)
**Actions:**
- [ ] Create DTO class with properties:
- `?string $iban` - IBAN with spaces stripped for storage
- `?string $accountHolder` - Account holder name
- `?string $bankName` - Name of the bank (optional)
- `bool $sepaMandateAccepted = false` - SEPA mandate checkbox
- [ ] Add Symfony validation constraints:
- `@Assert\Iban()` for IBAN
- `@Assert\NotBlank()` for IBAN and account holder
- `@Assert\Length(min: 2, max: 70)` for account holder
- `@Assert\Length(max: 70)` for bank name
- `@Assert\IsTrue()` for SEPA mandate
- [ ] Add methods:
- `getFormattedIban()` - Returns IBAN with spaces for display
- `getIbanWithoutSpaces()` - Returns IBAN without spaces for storage/API
**Notes:**
- Public properties (no constructor promotion needed for DTOs)
- BIC omitted (not required for German IBANs since 2016)
- Follow project DTO patterns
---
#### Task 1.2: Create PaymentDto
**Files:**
- `src/Form/Model/PaymentDto.php` (new)
**Actions:**
- [ ] Create DTO class with properties:
- `?string $paymentMethod` - 'transfer' or 'debit'
- `?BankAccountDto $bankAccount` - Bank account details (nullable)
- [ ] Add validation:
- `@Assert\Choice(choices: ['transfer', 'debit'])`
- `@Assert\Valid()` for bankAccount when debit selected
- [ ] Add method `requiresBankAccount(): bool` - returns true if debit
- [ ] Add getter/setter methods
**Notes:**
- Bank account only required for direct debit
- Validation must be conditional based on payment method
---
### Phase 2: Extend BookingCreateDto
#### Task 2.1: Add Payment Property to BookingCreateDto
**Files:**
- `src/Form/Model/BookingCreateDto.php`
**Actions:**
- [ ] Add property: `public PaymentDto $payment`
- [ ] Initialize in constructor: `$this->payment = new PaymentDto()`
- [ ] Ensure serialization works for session storage
**Notes:**
- Payment data stored in session alongside participant data
- Must survive page navigation
---
### Phase 3: Create Step 3 Form Types
#### Task 3.1: Create BankAccountType
**Status:** ✅ Simplified - BIC omitted
**Files:**
- `src/Form/BankAccountType.php` (new)
**Actions:**
- [ ] Create form type for `BankAccountDto`
- [ ] Add fields:
- `iban` - TextType with formatting help text (monospace font)
- `accountHolder` - TextType (required)
- `bankName` - TextType (optional)
- `sepaMandateAccepted` - CheckboxType with rich label (SEPA text)
- [ ] Add data transformer for IBAN:
- Strip spaces from IBAN on submit
- Format IBAN with spaces for display
- [ ] Add help text with IBAN format example: "DE12 3456 7890 1234 5678 90"
- [ ] Style SEPA mandate as prominent checkbox with simple legal text
**Notes:**
- Use monospace font for IBAN field
- Add inline validation feedback
- SEPA text: Simple generic authorization text (no Creditor ID needed)
---
#### Task 3.2: Create PaymentType
**Files:**
- `src/Form/PaymentType.php` (new)
**Actions:**
- [ ] Create form type for `PaymentDto`
- [ ] Add field: `paymentMethod` - ChoiceType (expanded radios)
- Choice 1: 'transfer' → "Überweisung"
- Choice 2: 'debit' → "Lastschrift"
- [ ] Add field: `bankAccount` - BankAccountType (conditional)
- [ ] Add form events to show/hide bank account fields:
- `PRE_SET_DATA` - Add bank account if debit selected
- `PRE_SUBMIT` - Add bank account if debit submitted
- [ ] Add JavaScript/HTMX to toggle bank account section visibility
**Notes:**
- Bank account section hidden when transfer selected
- Use Stimulus controller for toggle behavior
- Smooth transition when switching payment methods
---
#### Task 3.3: Create BookingCreateStep3Type
**Files:**
- `src/Form/BookingCreateStep3Type.php` (new)
**Actions:**
- [ ] Create form type for `BookingCreateDto` (uses payment property)
- [ ] Add field: `payment` - PaymentType
- [ ] Set data_class to `BookingCreateDto::class`
- [ ] Add validation groups: `['payment']`
**Notes:**
- Follows same pattern as Step1Type and Step2Type
- Validates only payment-related fields
---
### Phase 4: Create Step 3 Controller
#### Task 4.1: Create CreateStep3Controller
**Files:**
- `src/Controller/Booking/CreateStep3Controller.php` (new)
**Actions:**
- [ ] Create controller with two actions:
- `step3()` - Display form (GET)
- `processStep3()` - Process form (POST)
- [ ] Load `BookingCreateDto` from session
- [ ] Create form with `BookingCreateStep3Type`
- [ ] On valid submission:
- Update session with payment data
- Redirect to confirmation/summary page (or direct to API submission)
- [ ] Add "Back to Step 2" button
- [ ] Add route: `/booking/create/step-3`
- [ ] Add access control: `@IsGranted('IS_AUTHENTICATED_ANONYMOUSLY')`
**Notes:**
- Session key: same as Step 1/2 (`booking_create_dto`)
- Validate that Steps 1 & 2 are complete before showing Step 3
- Clear session on final submission
---
### Phase 5: Create Step 3 Template
#### Task 5.1: Create step_3.html.twig
**Files:**
- `templates/booking/create_step_3.html.twig` (new)
**Actions:**
- [ ] Create template extending base layout
- [ ] Add progress indicator (Step 1 → Step 2 → **Step 3** → Confirmation)
- [ ] Add payment method selection with clear labels
- [ ] Add conditional bank account section:
- Hidden by default
- Shows when direct debit selected
- Smooth CSS transition
- [ ] Add form with:
- Payment method radios (large, clear)
- Bank account fields (conditional)
- SEPA mandate checkbox with legal text
- "Back" and "Continue" buttons
- [ ] Add Stimulus controller for payment method toggle
- [ ] Add inline validation feedback
- [ ] Add help text for IBAN format examples
**Notes:**
- Clear visual hierarchy: payment method choice prominent
- Bank account section visually grouped
- SEPA text: "Ich ermächtige [Company] Zahlungen von meinem Konto mittels Lastschrift einzuziehen..."
---
### Phase 6: Update Navigation Flow
#### Task 6.1: Update CreateStep2Controller
**Files:**
- `src/Controller/Booking/CreateStep2Controller.php`
**Actions:**
- [ ] Change successful submission redirect:
- FROM: Confirmation page
- TO: `app_booking_create_step_3`
- [ ] Keep session data intact (don't clear)
**Notes:**
- Simple redirect change
- Session persistence already in place
---
#### Task 6.2: Add Navigation Helpers
**Files:**
- `src/Service/BookingSessionService.php` (new, optional)
**Actions:**
- [ ] Create service to manage booking session state (optional)
- [ ] Add methods:
- `getBookingDto(): ?BookingCreateDto`
- `saveBookingDto(BookingCreateDto $dto): void`
- `clearBookingSession(): void`
- `validateStepAccess(int $step): bool` - Check prerequisite steps
- [ ] Inject into controllers
**Notes:**
- Optional improvement for cleaner controller code
- Can be deferred if time-constrained
---
### Phase 7: Add Validation & Error Handling
**Status:** ✅ Simplified - Using Symfony's built-in validators only
**Notes:**
- Symfony's `@Assert\Iban` is sufficient for IBAN validation
- No custom validators needed
- German error messages configured in translation files
---
### Phase 8: Add SEPA Mandate Text Management
#### Task 8.1: Create SEPA Mandate Template
**Files:**
- `templates/booking/_sepa_mandate_text.html.twig` (new)
**Actions:**
- [ ] Create reusable template partial with SEPA mandate text
- [ ] Include variables:
- Company name
- Creditor ID (SEPA Gläubiger-ID)
- Mandate reference (generated per booking)
- [ ] Format as legal text with proper line breaks
- [ ] Add checkbox label referencing this text
**Notes:**
- Text must be legally compliant
- Consult legal team for exact wording
- Consider making mandate reference visible to user
---
### Phase 9: Frontend Enhancements
#### Task 9.1: Create Payment Toggle Stimulus Controller
**Files:**
- `assets/controllers/payment_toggle_controller.js` (new)
**Actions:**
- [ ] Create Stimulus controller to toggle bank account visibility
- [ ] Listen to payment method radio change
- [ ] Show/hide bank account section with smooth transition
- [ ] Clear bank account fields when switching to transfer
- [ ] Add/remove required attributes dynamically
**Notes:**
- Use CSS transitions for smooth UX
- Ensure accessibility (aria attributes)
- Works without JavaScript (progressive enhancement)
---
#### Task 9.2: Add IBAN Formatting Helper
**Files:**
- `assets/controllers/iban_formatter_controller.js` (new)
**Actions:**
- [ ] Create Stimulus controller for IBAN input
- [ ] Format IBAN with spaces as user types: `DE12 3456 7890 1234 5678 90`
- [ ] Strip spaces on submit
- [ ] Show character count/validation status
- [ ] Use monospace font for input
**Notes:**
- Real-time formatting improves UX
- Visual feedback for correct format
- Consider using library: `iban-formatter`
---
### Phase 10: Testing
#### Task 10.1: Create Unit Tests
**Files:**
- `tests/Form/Model/BankAccountDtoTest.php` (new)
- `tests/Form/Model/PaymentDtoTest.php` (new)
**Actions:**
- [ ] Test IBAN validation (valid/invalid formats)
- [ ] Test conditional validation (bank account required for debit)
- [ ] Test SEPA mandate validation
- [ ] Test account holder name validation
**Notes:**
- Cover edge cases: empty, malformed, international IBANs
- Test both valid and invalid inputs
---
#### Task 10.2: Create Integration Tests
**Files:**
- `tests/Controller/Booking/CreateStep3ControllerTest.php` (new)
**Actions:**
- [ ] Test form display
- [ ] Test transfer selection (no bank account)
- [ ] Test debit selection (requires bank account)
- [ ] Test validation errors
- [ ] Test session persistence
- [ ] Test navigation (back to Step 2)
**Notes:**
- Use test client to simulate user interaction
- Verify session state after each action
---
### Phase 11: Update API Submission
#### Task 11.1: Update BookingDataProcessor
**Status:** ✅ Simplified - BIC omitted from API payload
**Files:**
- `src/BusProNet/DataProcessor/BookingDataProcessor.php`
**Actions:**
- [ ] Update `createBookingRequestPayload()` method (when implemented in Phase 5)
- [ ] Add payment data to API payload:
- Payment method (`zahlart`)
- Bank account details (if debit):
- IBAN (`iban`)
- Account holder (`kontoinhaber`)
- Bank name (`bankname`) - optional
- [ ] Map payment method to API codes:
- 'transfer' → 'UE' (or appropriate API code)
- 'debit' → 'LS' (or appropriate API code)
**Notes:**
- BIC omitted (not required for SEPA since 2016)
- Check BPN API documentation for exact field names
- SEPA mandate reference may need to be generated
---
#### Task 11.2: Store SEPA Mandate Reference
**Files:**
- `src/Service/SepaMandateService.php` (new)
**Actions:**
- [ ] Create service to generate unique SEPA mandate references
- [ ] Format: `[PREFIX]-[BOOKING_ID]-[TIMESTAMP]`
- [ ] Store reference in booking data
- [ ] Make available for PDF invoice generation
**Notes:**
- Mandate reference must be unique and traceable
- Consider using UUID or sequential ID
---
## Database Considerations
### SEPA Mandate Storage
**Option 1: Store in Booking API Data**
- Mandate reference sent to BPN API
- Stored in BPN system
- Retrieved with booking data
**Option 2: Local Database Table**
- Create `sepa_mandates` table
- Store: mandate_id, booking_id, iban, date_signed, status
- Allows local tracking and reporting
**Recommendation:** Start with Option 1 (API storage), add Option 2 if needed for compliance/reporting
---
## Security Considerations
1. **HTTPS Required:** Bank details must be transmitted over HTTPS only
2. **Session Security:** Ensure session data encrypted
3. **Input Sanitization:** Strip/validate all bank account inputs
4. **CSRF Protection:** Ensure form has CSRF token
5. **Rate Limiting:** Prevent brute force on payment form
6. **Audit Trail:** Log payment method changes
---
## UX Considerations
1. **Clear Labels:** "Überweisung" and "Lastschrift" with explanations
2. **Visual Feedback:** Show selected payment method prominently
3. **Inline Validation:** Real-time IBAN format checking
4. **Error Messages:** Clear, actionable German error messages
5. **Help Text:** Examples of valid IBAN formats
6. **Progress Indicator:** Show user is on Step 3 of 4
7. **Mobile Friendly:** Large touch targets for payment method selection
---
## Error Handling
### Validation Errors
- Show inline next to field
- Highlight invalid fields in red
- Provide clear guidance on how to fix
### Session Errors
- If session expired, redirect to Step 1 with message
- Preserve as much data as possible
### API Errors
- If payment method not accepted by API, show clear error
- Suggest alternative payment method
---
## Localization
All text in German:
- **Überweisung:** Direct transfer
- **Lastschrift:** Direct debit
- **IBAN:** Internationale Bankkontonummer
- **BIC:** Bank Identifier Code
- **Kontoinhaber:** Account holder
- **SEPA-Mandat:** SEPA mandate
- **Bankname:** Bank name
Error messages in German with clear instructions.
---
## Dependencies
### PHP Libraries
- `symfony/validator` (already installed)
- `symfony/form` (already installed)
- Consider: `iban-validation/iban` for enhanced IBAN validation (optional)
### JavaScript Libraries
- Stimulus (already installed)
- Consider: Custom IBAN formatter or library
### No new major dependencies required
---
## Migration Path
1. Implement in order: Phases 1 → 2 → 3 → 4 → 5 → 6
2. Test each phase before moving to next
3. Phases 7-11 can be done in parallel after Phase 6
4. Deploy behind feature flag initially (optional)
---
## Open Questions
1. **SEPA Creditor ID:** What is the company's SEPA Gläubiger-ID?
2. **Payment Method Codes:** What are the exact BPN API codes for transfer/debit?
3. **Mandate Reference Format:** Any specific format requirements?
4. **Invoice Generation:** Does invoice need to show SEPA mandate reference?
5. **Default Payment Method:** Should we pre-select one method?
6. **International IBANs:** Support only German IBANs or EU-wide?
---
## Success Criteria
- [ ] User can select payment method
- [ ] Bank account fields shown only for direct debit
- [ ] IBAN validated correctly
- [ ] SEPA mandate text displayed and accepted
- [ ] Payment data stored in session
- [ ] Payment data submitted to API
- [ ] Session cleared after successful booking
- [ ] All validation works (unit + integration tests)
- [ ] Mobile-friendly UI
- [ ] Accessible (keyboard navigation, screen readers)
---
## Timeline Estimate
- **Phase 1-2:** DTOs & Validation - 2 hours
- **Phase 3:** Form Types - 2 hours
- **Phase 4:** Controller - 1 hour
- **Phase 5:** Template - 2 hours
- **Phase 6:** Navigation - 0.5 hour
- **Phase 7:** Custom Validators - 1 hour (optional)
- **Phase 8:** SEPA Text - 0.5 hour
- **Phase 9:** Frontend - 2 hours
- **Phase 10:** Testing - 2 hours
- **Phase 11:** API Integration - 1 hour
**Total:** ~14 hours (can be reduced if some phases skipped/simplified)
---
## References
- SEPA Direct Debit Scheme: https://www.europeanpaymentscouncil.eu/
- IBAN Validation: https://en.wikipedia.org/wiki/International_Bank_Account_Number
- Symfony Form Events: https://symfony.com/doc/current/form/events.html
- Symfony Validation: https://symfony.com/doc/current/validation.html
---
**End of Document**
@@ -46,4 +46,35 @@ trait BookingCreateTrait
return $this->redirectToRoute($route); return $this->redirectToRoute($route);
} }
/**
* Returns the total number of participants based on room selections.
*/
private function getParticipantsCount(BookingCreateDto $bookingCreateDto): int
{
return $this
->bookingService
->getParticipantsCount($bookingCreateDto->roomSelections, $bookingCreateDto->travel);
}
/**
* Prepares template variables for the booking summary sidebar.
*
* @return array<string, mixed> Array containing all variables needed for the summary partial
*/
private function getSummaryVariables(BookingCreateDto $bookingCreateDto): array
{
$participantsCount = $this->getParticipantsCount($bookingCreateDto);
$summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingCreateDto);
$roomAssignmentCounts = $this->bookingService->getRoomAssignmentCounts($bookingCreateDto);
$availableRooms = $bookingCreateDto->travel->getAvailableRooms();
$groupedSelectedRooms = $this->bookingService->groupRoomSelectionsByType($bookingCreateDto->getSelectedRooms(), $availableRooms);
return [
'participantsCount' => $participantsCount,
'pricingData' => $summary['pricing'],
'assignmentCounts' => $roomAssignmentCounts,
'groupedSelectedRooms' => $groupedSelectedRooms,
];
}
} }
@@ -166,20 +166,6 @@ class CreateStep2Controller extends AbstractController
return $response; return $response;
} }
/**
* Calculates the total number of participants based on room selections.
*
* @param BookingCreateDto $bookingCreateDto The booking DTO containing room selections
*
* @return int Total number of participants required
*/
private function getParticipantsCount(BookingCreateDto $bookingCreateDto): int
{
return $this
->bookingService
->getParticipantsCount($bookingCreateDto->roomSelections, $bookingCreateDto->travel);
}
/** /**
* Ensures the booking DTO has the correct number of participant objects. * Ensures the booking DTO has the correct number of participant objects.
* *
@@ -4,35 +4,37 @@ declare(strict_types=1);
namespace App\Controller\Booking; namespace App\Controller\Booking;
use App\BusProNet\Constants;
use App\Controller\Traits\HtmxControllerTrait;
use App\Form\BookingCreateStep3Type;
use App\Service\BookingService; use App\Service\BookingService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/** /**
* Handles the third step of the booking creation process. * Handles the third step of the booking creation process (payment method selection).
*
* This controller manages the booking confirmation and final review
* before completing the booking process.
*/ */
class CreateStep3Controller extends AbstractController class CreateStep3Controller extends AbstractController
{ {
use BookingCreateTrait; use BookingCreateTrait;
use BookingExceptionHandlerTrait; use BookingExceptionHandlerTrait;
use HtmxControllerTrait;
public function __construct( public function __construct(
private readonly BookingService $bookingCreateService, private readonly BookingService $bookingService,
) { ) {
} }
/** /**
* Displays the booking confirmation page. * Displays and processes the payment method form.
*/ */
#[Route('/bookings/create/confirm', name: 'app_booking_create_step_3')] #[Route('/bookings/create/payment', name: 'app_booking_create_step_3')]
public function confirm(Request $request): Response public function step3(Request $request): Response
{ {
$result = $this->getOrCreateBookingCreateDto($this->bookingCreateService, $request); $result = $this->getOrCreateBookingCreateDto($this->bookingService, $request);
if ($result instanceof Response) { if ($result instanceof Response) {
return $result; return $result;
} }
@@ -43,8 +45,48 @@ class CreateStep3Controller extends AbstractController
return $redirect; return $redirect;
} }
$form = $this->createForm(BookingCreateStep3Type::class, $bookingCreateDto);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->bookingService->saveBookingCreateDto($request, $bookingCreateDto);
// TODO: Redirect to confirmation page or direct API submission
$this->addFlash('success', 'Zahlungsart erfolgreich ausgewählt.');
return $this->redirectToRoute('app_booking_create_step_3');
}
return $this->render('booking/create_step_3.html.twig', [ return $this->render('booking/create_step_3.html.twig', [
'bookingCreateDto' => $bookingCreateDto, 'bookingCreateDto' => $bookingCreateDto,
'form' => $form->createView(),
...$this->getSummaryVariables($bookingCreateDto),
]);
}
/**
* Handles HTMX refresh when payment method changes.
*/
#[Route('/bookings/create/payment/refresh', name: 'app_booking_create_step_3_refresh')]
public function refresh(Request $request): Response
{
$result = $this->getOrCreateBookingCreateDto($this->bookingService, $request);
if ($result instanceof Response) {
return $result;
}
$bookingCreateDto = $result;
$form = $this->createForm(BookingCreateStep3Type::class, $bookingCreateDto, [
'validation_groups' => false,
]);
$form->handleRequest($request);
$this->bookingService->saveBookingCreateDto($request, $bookingCreateDto);
return $this->render('booking/create_step_3.html.twig', [
'bookingCreateDto' => $bookingCreateDto,
'form' => $form->createView(),
...$this->getSummaryVariables($bookingCreateDto),
]); ]);
} }
} }
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Form\Model\BankAccountDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class BankAccountType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('iban', TextType::class, [
'label' => 'IBAN',
'required' => true,
'attr' => [
'class' => 'font-mono',
'placeholder' => 'DE12 3456 7890 1234 5678 90',
],
])
->add('accountHolder', TextType::class, [
'label' => 'Kontoinhaber',
'required' => true,
])
->add('bankName', TextType::class, [
'label' => 'Bankname',
'required' => false,
'attr' => [
'placeholder' => 'optional',
],
])
->add('sepaMandateAccepted', CheckboxType::class, [
'label' => '<span class="text-gray-700 font-normal">Hiermit ermächtige/n ich/wir Sie widerruflich, die von mir/uns zu entrichtende Zahlung bei Fälligkeit zu Lasten meines/unseres Girokontos durch Lastschrift einzuziehen.</span><br>' .
'<span class="text-red-600 font-semibold">Bei kurzfristigen Buchungen (ab 2 Wochen vor Reisebeginn) ist Lastschrift NICHT mehr möglich.</span>',
'required' => true,
'label_html' => true,
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => BankAccountDto::class,
]);
}
}
+99
View File
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\BusProNet\Constants;
use App\Form\Model\BookingCreateDto;
use Carbon\CarbonImmutable;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
class BookingCreateStep3Type extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('paymentMethod', ChoiceType::class, [
'label' => 'Bitte wähle hier die gewünschte Zahlungsart:',
'choices' => [
'Überweisung' => Constants::PAYMENT_METHOD_TRANSFER,
'Lastschrift (Einzugsermächtigungsverfahren)' => Constants::PAYMENT_METHOD_DEBIT,
],
'expanded' => true,
'required' => true,
'placeholder' => false,
'label_html' => true,
'choice_attr' => function (string $choice) use ($options): array {
if (Constants::PAYMENT_METHOD_DEBIT === $choice && false === $this->isDebitAvailable($options['data'])) {
return [
'readonly' => true,
'data-tooltip' => 'nicht mehr verfügbar (weniger als 14 Tage vor Reisebeginn)',
];
}
return [];
},
])
;
// Add bank account fields conditionally based on payment method
$builder->addEventListener(FormEvents::POST_SET_DATA, function (FormEvent $event): void {
$bookingDto = $event->getData();
if (Constants::PAYMENT_METHOD_DEBIT === $bookingDto->paymentMethod) {
$this->addBankAccountField($event->getForm());
}
});
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event): void {
$data = $event->getData();
$form = $event->getForm();
$paymentMethod = $data['paymentMethod'] ?? null;
if ($form->has('bankAccount')) {
$form->remove('bankAccount');
}
if (Constants::PAYMENT_METHOD_DEBIT === $paymentMethod) {
$this->addBankAccountField($form);
}
});
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => BookingCreateDto::class,
]);
}
/**
* Adds the bank account field to the form.
*/
private function addBankAccountField($form): void
{
$form->add('bankAccount', BankAccountType::class, [
'label' => false,
]);
}
/**
* Checks if direct debit payment is available based on travel date.
*
* Direct debit is only available if the travel starts at least 14 days from now.
*/
private function isDebitAvailable(BookingCreateDto $bookingDto): bool
{
$travelStartDate = CarbonImmutable::instance($bookingDto->travel->dateFrom);
$now = CarbonImmutable::now();
$daysUntilTravel = $now->diffInDays($travelStartDate, false);
return $daysUntilTravel >= 14;
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Form\Model;
use Symfony\Component\Validator\Constraints as Assert;
/**
* DTO for bank account information used in payment processing.
*/
class BankAccountDto
{
#[Assert\NotBlank(message: 'Bitte geben Sie Ihre IBAN ein.')]
#[Assert\Iban(message: 'Die eingegebene IBAN ist ungültig.')]
public ?string $iban = null;
#[Assert\NotBlank(message: 'Bitte geben Sie den Kontoinhaber ein.')]
#[Assert\Length(
min: 2,
max: 70,
minMessage: 'Der Kontoinhaber muss mindestens {{ limit }} Zeichen lang sein.',
maxMessage: 'Der Kontoinhaber darf maximal {{ limit }} Zeichen lang sein.'
)]
public ?string $accountHolder = null;
#[Assert\Length(
max: 70,
maxMessage: 'Der Bankname darf maximal {{ limit }} Zeichen lang sein.'
)]
public ?string $bankName = null;
#[Assert\IsTrue(message: 'Bitte akzeptieren Sie das SEPA-Mandat.')]
public bool $sepaMandateAccepted = false;
/**
* Returns IBAN formatted with spaces for display (e.g., DE12 3456 7890 1234 5678 90).
*/
public function getFormattedIban(): ?string
{
if (null === $this->iban) {
return null;
}
$cleanIban = $this->getIbanWithoutSpaces();
return chunk_split($cleanIban, 4, ' ');
}
/**
* Returns IBAN without spaces for storage and API submission.
*/
public function getIbanWithoutSpaces(): ?string
{
if (null === $this->iban) {
return null;
}
return (string) preg_replace('/\s+/', '', $this->iban);
}
}
+46
View File
@@ -2,6 +2,7 @@
namespace App\Form\Model; namespace App\Form\Model;
use App\BusProNet\Constants;
use App\BusProNet\Model\Travel; use App\BusProNet\Model\Travel;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface; use Symfony\Component\Validator\Context\ExecutionContextInterface;
@@ -22,6 +23,14 @@ class BookingCreateDto implements BookingDtoInterface
#[Assert\Valid] #[Assert\Valid]
public array $participants = []; public array $participants = [];
#[Assert\Choice(
choices: [Constants::PAYMENT_METHOD_TRANSFER, Constants::PAYMENT_METHOD_DEBIT],
message: 'Bitte wählen Sie eine gültige Zahlungsart.'
)]
public ?string $paymentMethod = Constants::PAYMENT_METHOD_TRANSFER;
public ?BankAccountDto $bankAccount = null;
public function __construct(public Travel $travel, public int $hotelId) public function __construct(public Travel $travel, public int $hotelId)
{ {
} }
@@ -95,4 +104,41 @@ class BookingCreateDto implements BookingDtoInterface
->addViolation(); ->addViolation();
} }
} }
#[Assert\Callback]
public function validateBankAccount(ExecutionContextInterface $context): void
{
if (Constants::PAYMENT_METHOD_DEBIT !== $this->paymentMethod) {
return;
}
if (null === $this->bankAccount) {
$context->buildViolation('Bitte geben Sie Ihre Bankverbindung an.')
->atPath('bankAccount')
->addViolation();
return;
}
// Validate IBAN
if (null === $this->bankAccount->iban || '' === trim($this->bankAccount->iban)) {
$context->buildViolation('Bitte geben Sie Ihre IBAN ein.')
->atPath('bankAccount.iban')
->addViolation();
}
// Validate account holder
if (null === $this->bankAccount->accountHolder || '' === trim($this->bankAccount->accountHolder)) {
$context->buildViolation('Bitte geben Sie den Kontoinhaber ein.')
->atPath('bankAccount.accountHolder')
->addViolation();
}
// Validate SEPA mandate
if (false === $this->bankAccount->sepaMandateAccepted) {
$context->buildViolation('Bitte akzeptieren Sie das SEPA-Mandat.')
->atPath('bankAccount.sepaMandateAccepted')
->addViolation();
}
}
} }
+22
View File
@@ -12,6 +12,28 @@ use Symfony\Component\Validator\Constraints as Assert;
#[AppAssert\Participant(groups: ['booking_edit', 'booking_create_step_2'])] #[AppAssert\Participant(groups: ['booking_edit', 'booking_create_step_2'])]
class ParticipantDto class ParticipantDto
{ {
/**
* List of dynamic participant fields that can be conditionally hidden/shown.
*/
public const DYNAMIC_FIELDS = [
'assignedRoomId',
'remarksRoom',
'courses',
'additionalServices',
'board',
'rentals',
'rentalInsurance',
'skiPass',
'transportationOutbound',
'transportationInbound',
'pickupOutbound',
'pickupInbound',
'parking',
'licensePlate',
'bulkInsuranceBooking',
'insurance',
];
public ?int $index = null; public ?int $index = null;
public ?int $addressId = null; public ?int $addressId = null;
public ?int $personId = null; public ?int $personId = null;
+106
View File
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\BusProNet\Constants;
use App\Form\Model\BookingCreateDto;
use Carbon\CarbonImmutable;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
class PaymentType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('paymentMethod', ChoiceType::class, [
'label' => 'Zahlungsart',
'help' => 'Lastschrift ist nur bis 14 Tage vor Reisebeginn möglich',
'choices' => [
'Überweisung' => Constants::PAYMENT_METHOD_TRANSFER,
'Lastschrift' => Constants::PAYMENT_METHOD_DEBIT,
],
'expanded' => true,
'required' => true,
'placeholder' => false,
])
;
// Add bank account fields conditionally based on payment method
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void {
$bookingDto = $event->getData();
// Add choice_attr dynamically based on travel date
$paymentMethodField = $event->getForm()->get('paymentMethod');
$paymentMethodConfig = $paymentMethodField->getConfig();
// Rebuild the field with dynamic choice_attr
$event->getForm()->add('paymentMethod', ChoiceType::class, [
'label' => 'Zahlungsart',
'help' => 'Lastschrift ist nur bis 14 Tage vor Reisebeginn möglich',
'choices' => [
'Überweisung' => Constants::PAYMENT_METHOD_TRANSFER,
'Lastschrift' => Constants::PAYMENT_METHOD_DEBIT,
],
'expanded' => true,
'required' => true,
'placeholder' => false,
'choice_attr' => function (string $choice) use ($bookingDto): array {
if (Constants::PAYMENT_METHOD_DEBIT === $choice && false === $this->isDebitAvailable($bookingDto)) {
return [
'readonly' => true,
'data-tooltip' => 'nicht mehr verfügbar (weniger als 14 Tage vor Reisebeginn)',
];
}
return [];
},
]);
// Add bank account fields if debit is selected
if (Constants::PAYMENT_METHOD_DEBIT === $bookingDto->paymentMethod) {
$event->getForm()->add('bankAccount', BankAccountType::class, [
'label' => false,
]);
}
});
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event): void {
$data = $event->getData();
$paymentMethod = $data['paymentMethod'] ?? null;
if (Constants::PAYMENT_METHOD_DEBIT === $paymentMethod) {
$event->getForm()->add('bankAccount', BankAccountType::class, [
'label' => false,
]);
}
});
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'inherit_data' => true,
]);
}
/**
* Checks if direct debit payment is available based on travel date.
*
* Direct debit is only available if the travel starts at least 14 days from now.
*/
private function isDebitAvailable(BookingCreateDto $bookingDto): bool
{
$travelStartDate = CarbonImmutable::instance($bookingDto->travel->dateFrom);
$now = CarbonImmutable::now();
$daysUntilTravel = $now->diffInDays($travelStartDate, false);
return $daysUntilTravel >= 14;
}
}
+1 -7
View File
@@ -281,13 +281,7 @@
{% endif %} {% endif %}
{% if participant.licensePlate is defined %} {% if participant.licensePlate is defined %}
<div class="mt-4"> <div class="mt-4">
{{ form_row(participant.licensePlate, { {{ form_row(participant.licensePlate) }}
'attr': {
'hx-trigger': 'change',
'hx-post': path('app_booking_create_step_2_refresh'),
'hx-swap': 'none'
}
}) }}
</div> </div>
{% endif %} {% endif %}
+77
View File
@@ -1,4 +1,81 @@
{% extends 'layout.html.twig' %} {% extends 'layout.html.twig' %}
{# Local form theme override for consistent fieldset rendering #}
{% use 'forms.html.twig' %}
{% block form_row %}
{%- if form.vars.expanded is defined and form.vars.expanded -%}
{# Expanded forms get fieldset wrapper #}
<fieldset class="mb-1">
<legend class="font-semibold mb-1">
{{- form.vars.label -}}
</legend>
{{- form_widget(form, {
'attr': attr|default({})
}) -}}
{{- form_errors(form) -}}
{{- form_help(form) -}}
</fieldset>
{%- else -%}
{{- parent() -}}
{%- endif -%}
{% endblock %}
{% block content %} {% block content %}
{% include '_partials/_flashes.html.twig' %}
<h1>Neue Buchung</h1>
<div class="grid grid-cols-3 gap-8">
<div class="col-span-2">
<h2 class="mb-6">Zahlungsart</h2>
{{ form_start(form, {'attr': {'novalidate': 'novalidate'}}) }}
{# Payment method selection with HTMX #}
<div id="form-payment"
hx-post="{{ path('app_booking_create_step_3_refresh') }}"
hx-trigger="change from:input[type='radio']"
hx-target="#form-payment"
hx-select="#form-payment"
hx-swap="outerHTML">
{{ form_row(form.paymentMethod) }}
{# Bank account section - conditionally rendered #}
{% if form.bankAccount is defined %}
<div class="border border-gray-300 rounded-lg p-4 bg-gray-50 mt-4">
<h3 class="font-semibold text-lg mb-4">Bankverbindung</h3>
<div class="space-y-4">
{{ form_row(form.bankAccount.iban) }}
{{ form_row(form.bankAccount.accountHolder) }}
{{ form_row(form.bankAccount.bankName) }}
<div class="mt-6 p-4 bg-blue-50 border border-blue-200 rounded">
{{ form_row(form.bankAccount.sepaMandateAccepted, {
'label_attr': {'class': 'text-sm'}
}) }}
</div>
</div>
</div>
{% endif %}
</div>
<div class="flex justify-between pt-6">
<a href="{{ path('app_booking_create_step_2') }}" class="button bg-button bg-button--secondary">Zurück</a>
<button type="submit" class="button bg-button bg-button--secondary">Weiter</button>
</div>
{{ form_end(form) }}
</div>
<div id="booking-summary">
{% include 'booking/_summary.html.twig' with {
'bookingCreateDto': bookingCreateDto,
'participantCount': participantsCount,
'groupedSelectedRooms': groupedSelectedRooms,
'assignmentCounts': assignmentCounts
} %}
</div>
</div>
{% endblock %} {% endblock %}