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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
|
||||
@@ -4,35 +4,37 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Booking;
|
||||
|
||||
use App\BusProNet\Constants;
|
||||
use App\Controller\Traits\HtmxControllerTrait;
|
||||
use App\Form\BookingCreateStep3Type;
|
||||
use App\Service\BookingService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Handles the third step of the booking creation process.
|
||||
*
|
||||
* This controller manages the booking confirmation and final review
|
||||
* before completing the booking process.
|
||||
* Handles the third step of the booking creation process (payment method selection).
|
||||
*/
|
||||
class CreateStep3Controller extends AbstractController
|
||||
{
|
||||
use BookingCreateTrait;
|
||||
use BookingExceptionHandlerTrait;
|
||||
use HtmxControllerTrait;
|
||||
|
||||
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')]
|
||||
public function confirm(Request $request): Response
|
||||
#[Route('/bookings/create/payment', name: 'app_booking_create_step_3')]
|
||||
public function step3(Request $request): Response
|
||||
{
|
||||
$result = $this->getOrCreateBookingCreateDto($this->bookingCreateService, $request);
|
||||
$result = $this->getOrCreateBookingCreateDto($this->bookingService, $request);
|
||||
if ($result instanceof Response) {
|
||||
return $result;
|
||||
}
|
||||
@@ -43,8 +45,48 @@ class CreateStep3Controller extends AbstractController
|
||||
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', [
|
||||
'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;
|
||||
|
||||
use App\BusProNet\Constants;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
@@ -22,6 +23,14 @@ class BookingCreateDto implements BookingDtoInterface
|
||||
#[Assert\Valid]
|
||||
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)
|
||||
{
|
||||
}
|
||||
@@ -95,4 +104,41 @@ class BookingCreateDto implements BookingDtoInterface
|
||||
->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'])]
|
||||
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 $addressId = 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 %}
|
||||
{% if participant.licensePlate is defined %}
|
||||
<div class="mt-4">
|
||||
{{ form_row(participant.licensePlate, {
|
||||
'attr': {
|
||||
'hx-trigger': 'change',
|
||||
'hx-post': path('app_booking_create_step_2_refresh'),
|
||||
'hx-swap': 'none'
|
||||
}
|
||||
}) }}
|
||||
{{ form_row(participant.licensePlate) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -1,4 +1,81 @@
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
|
||||
Reference in New Issue
Block a user