diff --git a/docs/BOOKING_PAYMENT_STEP.md b/docs/BOOKING_PAYMENT_STEP.md new file mode 100644 index 0000000..84ca83e --- /dev/null +++ b/docs/BOOKING_PAYMENT_STEP.md @@ -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 +
+ {{ form_row(form.paymentMethod) }} + + {# Conditionally rendered bank account section #} + {% if form.bankAccount is defined %} + ... + {% endif %} +
+``` + +**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** \ No newline at end of file diff --git a/src/Controller/Booking/BookingCreateTrait.php b/src/Controller/Booking/BookingCreateTrait.php index abf9f44..0df8d33 100644 --- a/src/Controller/Booking/BookingCreateTrait.php +++ b/src/Controller/Booking/BookingCreateTrait.php @@ -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 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, + ]; + } } diff --git a/src/Controller/Booking/CreateStep2Controller.php b/src/Controller/Booking/CreateStep2Controller.php index 0a34216..2f194b8 100644 --- a/src/Controller/Booking/CreateStep2Controller.php +++ b/src/Controller/Booking/CreateStep2Controller.php @@ -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. * diff --git a/src/Controller/Booking/CreateStep3Controller.php b/src/Controller/Booking/CreateStep3Controller.php index 6b31450..13cad92 100644 --- a/src/Controller/Booking/CreateStep3Controller.php +++ b/src/Controller/Booking/CreateStep3Controller.php @@ -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), ]); } } diff --git a/src/Form/BankAccountType.php b/src/Form/BankAccountType.php new file mode 100644 index 0000000..8a60431 --- /dev/null +++ b/src/Form/BankAccountType.php @@ -0,0 +1,53 @@ +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' => '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.
' . + 'Bei kurzfristigen Buchungen (ab 2 Wochen vor Reisebeginn) ist Lastschrift NICHT mehr möglich.', + 'required' => true, + 'label_html' => true, + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => BankAccountDto::class, + ]); + } +} diff --git a/src/Form/BookingCreateStep3Type.php b/src/Form/BookingCreateStep3Type.php new file mode 100644 index 0000000..12a8c92 --- /dev/null +++ b/src/Form/BookingCreateStep3Type.php @@ -0,0 +1,99 @@ +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; + } +} diff --git a/src/Form/Model/BankAccountDto.php b/src/Form/Model/BankAccountDto.php new file mode 100644 index 0000000..fa46110 --- /dev/null +++ b/src/Form/Model/BankAccountDto.php @@ -0,0 +1,61 @@ +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); + } +} diff --git a/src/Form/Model/BookingCreateDto.php b/src/Form/Model/BookingCreateDto.php index 3194500..d3b3827 100644 --- a/src/Form/Model/BookingCreateDto.php +++ b/src/Form/Model/BookingCreateDto.php @@ -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(); + } + } } diff --git a/src/Form/Model/ParticipantDto.php b/src/Form/Model/ParticipantDto.php index cfe7c4e..35e59a2 100644 --- a/src/Form/Model/ParticipantDto.php +++ b/src/Form/Model/ParticipantDto.php @@ -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; diff --git a/src/Form/PaymentType.php b/src/Form/PaymentType.php new file mode 100644 index 0000000..4f1e6af --- /dev/null +++ b/src/Form/PaymentType.php @@ -0,0 +1,106 @@ +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; + } +} diff --git a/templates/booking/create_step_2.html.twig b/templates/booking/create_step_2.html.twig index 36d3d25..c97d737 100644 --- a/templates/booking/create_step_2.html.twig +++ b/templates/booking/create_step_2.html.twig @@ -281,13 +281,7 @@ {% endif %} {% if participant.licensePlate is defined %}
- {{ form_row(participant.licensePlate, { - 'attr': { - 'hx-trigger': 'change', - 'hx-post': path('app_booking_create_step_2_refresh'), - 'hx-swap': 'none' - } - }) }} + {{ form_row(participant.licensePlate) }}
{% endif %} diff --git a/templates/booking/create_step_3.html.twig b/templates/booking/create_step_3.html.twig index 50e3410..6e75580 100644 --- a/templates/booking/create_step_3.html.twig +++ b/templates/booking/create_step_3.html.twig @@ -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 #} +
+ + {{- form.vars.label -}} + + {{- form_widget(form, { + 'attr': attr|default({}) + }) -}} + {{- form_errors(form) -}} + {{- form_help(form) -}} +
+ {%- else -%} + {{- parent() -}} + {%- endif -%} +{% endblock %} + {% block content %} + {% include '_partials/_flashes.html.twig' %} + +

Neue Buchung

+ +
+
+

Zahlungsart

+ + {{ form_start(form, {'attr': {'novalidate': 'novalidate'}}) }} + + {# Payment method selection with HTMX #} +
+ {{ form_row(form.paymentMethod) }} + + {# Bank account section - conditionally rendered #} + {% if form.bankAccount is defined %} +
+

Bankverbindung

+ +
+ {{ form_row(form.bankAccount.iban) }} + {{ form_row(form.bankAccount.accountHolder) }} + {{ form_row(form.bankAccount.bankName) }} + +
+ {{ form_row(form.bankAccount.sepaMandateAccepted, { + 'label_attr': {'class': 'text-sm'} + }) }} +
+
+
+ {% endif %} +
+ +
+ Zurück + +
+ + {{ form_end(form) }} +
+ +
+ {% include 'booking/_summary.html.twig' with { + 'bookingCreateDto': bookingCreateDto, + 'participantCount': participantsCount, + 'groupedSelectedRooms': groupedSelectedRooms, + 'assignmentCounts': assignmentCounts + } %} +
+
{% endblock %}