feat: implement payment step
This commit is contained in:
@@ -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),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 %}
|
||||||
|
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user