feat: improved mutability checks for personal data in edit or create mode

This commit is contained in:
Björn Fromme
2026-03-16 11:59:11 +01:00
parent ff39bf365c
commit 43823dbda1
27 changed files with 435 additions and 233 deletions
@@ -69,6 +69,20 @@ class BookingDataProcessor
$participantData = ParticipantDto::fromPersonalData($participant); $participantData = ParticipantDto::fromPersonalData($participant);
$participantData->index = $index; $participantData->index = $index;
// First participant (applicant): copy address from applicant if participant address is empty
// BPN API may return full address only in <anmelder> but minimal/empty address in <teilnehmer id="1">
// Only copy if first participant has no street (indicating empty/incomplete address)
// This allows applicant and first participant to be different people with different addresses
if (0 === $index && null !== $booking->applicant->address) {
$isEmpty = null === $participantData->address
|| null === $participantData->address->street
|| '' === trim($participantData->address->street);
if ($isEmpty) {
$participantData->address = clone $booking->applicant->address;
}
}
// Extract service selections from booking and assign to participant DTO // Extract service selections from booking and assign to participant DTO
$participantData->courses = $booking $participantData->courses = $booking
->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_COURSES); ->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_COURSES);
@@ -429,13 +443,16 @@ class BookingDataProcessor
* Maps insurance to the participant. Adds new insurances to the booking if they don't exist. * Maps insurance to the participant. Adds new insurances to the booking if they don't exist.
* Insurance can be either individual or package-based, with automatic price-tier adjustment. * Insurance can be either individual or package-based, with automatic price-tier adjustment.
* *
* IMPORTANT: Excludes synthetic "no insurance" option from BPN transmission.
*
* @param ParticipantDto $participant The participant data from the form * @param ParticipantDto $participant The participant data from the form
* @param Booking $bookingData The booking data object to update * @param Booking $bookingData The booking data object to update
* @param Travel $travelData The travel data containing available insurances * @param Travel $travelData The travel data containing available insurances
*/ */
private function processInsurance(ParticipantDto $participant, Booking $bookingData, Travel $travelData): void private function processInsurance(ParticipantDto $participant, Booking $bookingData, Travel $travelData): void
{ {
if (null === $participant->insurance) { // Skip if no insurance or if participant selected "keine Versicherung gewünscht"
if (null === $participant->insurance || $participant->insurance->isNoInsurance()) {
return; return;
} }
@@ -760,13 +777,9 @@ class BookingDataProcessor
'nationalitaet' => $firstParticipant->nationality ?? '', 'nationalitaet' => $firstParticipant->nationality ?? '',
]; ];
// Include BPN IDs for linking to existing records (authenticated users) // DO NOT include personId or addressId in create mode
if (null !== $firstParticipant->addressId) { // BPN will automatically match existing customers by exact personal data (name, DOB, address)
$payload['anmelder']['idadresse'] = $firstParticipant->addressId; // Including IDs would prevent automatic matching and could cause data inconsistencies
}
if (null !== $firstParticipant->personId) {
$payload['anmelder']['idadresseperson'] = $firstParticipant->personId;
}
if (null !== $firstParticipant->dateOfBirth) { if (null !== $firstParticipant->dateOfBirth) {
$payload['anmelder']['geburtsdatum'] = $firstParticipant->dateOfBirth->format('d.m.Y'); $payload['anmelder']['geburtsdatum'] = $firstParticipant->dateOfBirth->format('d.m.Y');
@@ -798,13 +811,9 @@ class BookingDataProcessor
'nationalitaet' => $participant->nationality ?? '', 'nationalitaet' => $participant->nationality ?? '',
]; ];
// Include BPN IDs for linking to existing records (authenticated users) // DO NOT include personId or addressId in create mode
if (null !== $participant->addressId) { // BPN will automatically match existing customers by exact personal data (name, DOB, address)
$participantData['idadresse'] = $participant->addressId; // Including IDs would prevent automatic matching and could cause data inconsistencies
}
if (null !== $participant->personId) {
$participantData['idadresseperson'] = $participant->personId;
}
if (null !== $participant->dateOfBirth) { if (null !== $participant->dateOfBirth) {
$participantData['geburtsdatum'] = $participant->dateOfBirth->format('d.m.Y'); $participantData['geburtsdatum'] = $participant->dateOfBirth->format('d.m.Y');
@@ -1085,6 +1094,7 @@ class BookingDataProcessor
* Collects insurance mappings. * Collects insurance mappings.
* *
* CRITICAL: Insurance data is only included in CREATE flow, not in UPDATE flow. * CRITICAL: Insurance data is only included in CREATE flow, not in UPDATE flow.
* IMPORTANT: Excludes synthetic "no insurance" option from BPN transmission.
* *
* @return array<string, array<int>> Map of insurance ID to participant IDs * @return array<string, array<int>> Map of insurance ID to participant IDs
*/ */
@@ -1095,7 +1105,8 @@ class BookingDataProcessor
foreach ($bookingDto->participants as $index => $participant) { foreach ($bookingDto->participants as $index => $participant) {
$participantId = $index + 1; $participantId = $index + 1;
if (null !== $participant->insurance) { // Exclude synthetic "keine Versicherung gewünscht" option from BPN XML
if (null !== $participant->insurance && false === $participant->insurance->isNoInsurance()) {
$insuranceMap[$participant->insurance->id][] = $participantId; $insuranceMap[$participant->insurance->id][] = $participantId;
} }
} }
-6
View File
@@ -4,8 +4,6 @@ declare(strict_types=1);
namespace App\BusProNet\Model; namespace App\BusProNet\Model;
use Symfony\Component\Validator\Constraints as Assert;
/** /**
* Represents a physical address with street, postal code, city, and country information. * Represents a physical address with street, postal code, city, and country information.
* *
@@ -15,18 +13,14 @@ use Symfony\Component\Validator\Constraints as Assert;
*/ */
class Address class Address
{ {
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data', 'applicant_address'])]
public ?string $street = null; public ?string $street = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data', 'applicant_address'])]
public ?string $postCode = null; public ?string $postCode = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data', 'applicant_address'])]
public ?string $city = null; public ?string $city = null;
public ?string $district = null; public ?string $district = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data', 'applicant_address'])]
public ?string $country = null; public ?string $country = null;
/** /**
+22
View File
@@ -16,6 +16,15 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
*/ */
class Insurance class Insurance
{ {
/**
* Special ID for the synthetic "no insurance" option.
*
* This option is injected into the insurance selection list to force explicit user choice
* for legal compliance. When selected, it satisfies validation requirements but transmits
* no insurance data to the BPN API.
*/
public const NO_INSURANCE_ID = '0';
#[Groups(['api:single', 'api:list'])] #[Groups(['api:single', 'api:list'])]
public ?string $id = null; public ?string $id = null;
@@ -180,4 +189,17 @@ class Insurance
return array_values(array_unique($urls)); return array_values(array_unique($urls));
} }
/**
* Checks if this is the synthetic "no insurance" option.
*
* The "no insurance" option is a UI construct used to force explicit user choice.
* It should be excluded from BPN API transmission.
*
* @return bool True if this is the "no insurance" option
*/
public function isNoInsurance(): bool
{
return self::NO_INSURANCE_ID === $this->id;
}
} }
@@ -106,6 +106,26 @@ class IndexController extends AbstractController
return $agency->id; return $agency->id;
} }
/**
* Cancels the active booking session and returns to the login page.
*
* This endpoint allows users to exit the booking flow at any time by
* clearing the booking session data and redirecting them back to the
* regular login screen.
*/
#[Route('/bookings/cancel', name: 'app_booking_cancel')]
public function cancel(Request $request): Response
{
// Clear the booking session
$this->bookingService->clearBookingSession($request);
// Add a flash message to inform the user
$this->addFlash('info', 'Buchung abgebrochen.');
// Redirect to login page
return $this->redirectToRoute('app_login');
}
/** /**
* Displays user-friendly error messages for booking initialization failures. * Displays user-friendly error messages for booking initialization failures.
* *
@@ -322,7 +322,7 @@ class IndexController extends AbstractController
} }
// Refresh availability data // Refresh availability data
$availabilities = $this->travelDataService->getAvailabilityDataCached($bookingDto->travel->id); $availabilities = $this->travelDataService->getAvailabilityData($bookingDto->travel->id, cached: true);
if (null !== $availabilities) { if (null !== $availabilities) {
$this->travelDataService->patchAvailabilities($bookingDto->travel, $availabilities); $this->travelDataService->patchAvailabilities($bookingDto->travel, $availabilities);
} }
@@ -505,7 +505,7 @@ class IndexController extends AbstractController
private function refreshFromSession(BookingDto $formData): BookingDto private function refreshFromSession(BookingDto $formData): BookingDto
{ {
// Refresh availability data // Refresh availability data
$availabilities = $this->travelDataService->getAvailabilityDataCached($formData->travel->id); $availabilities = $this->travelDataService->getAvailabilityData($formData->travel->id, cached: true);
if (null !== $availabilities) { if (null !== $availabilities) {
$this->travelDataService->patchAvailabilities($formData->travel, $availabilities); $this->travelDataService->patchAvailabilities($formData->travel, $availabilities);
} }
-6
View File
@@ -11,7 +11,6 @@ use App\Validator\Constraints as AppAssert;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
#[AppAssert\Participant(groups: ['booking_edit', 'booking_create'])] #[AppAssert\Participant(groups: ['booking_edit', 'booking_create'])]
#[AppAssert\ApplicantAddress(groups: ['booking_create'])]
class ParticipantDto class ParticipantDto
{ {
/** /**
@@ -42,10 +41,8 @@ class ParticipantDto
public bool $mutable = false; public bool $mutable = false;
public bool $touched = false; public bool $touched = false;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['booking_edit', 'booking_create'])]
public ?string $firstName = null; public ?string $firstName = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['booking_edit', 'booking_create'])]
public ?string $lastName = null; public ?string $lastName = null;
public ?string $title = null; public ?string $title = null;
public ?string $gender = null; public ?string $gender = null;
@@ -55,10 +52,8 @@ class ParticipantDto
public ?string $shoeSize = null; public ?string $shoeSize = null;
public ?string $weight = null; public ?string $weight = null;
#[Assert\NotNull(message: 'Bitte angeben', groups: ['booking_edit', 'booking_create'])]
public ?\DateTimeImmutable $dateOfBirth = null; public ?\DateTimeImmutable $dateOfBirth = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['booking_edit', 'booking_create'])]
#[Assert\Email(message: 'Bitte eine gültige E-Mail Adresse angeben', mode: 'strict', groups: ['booking_edit', 'booking_create'])] #[Assert\Email(message: 'Bitte eine gültige E-Mail Adresse angeben', mode: 'strict', groups: ['booking_edit', 'booking_create'])]
public ?string $email = null; public ?string $email = null;
@@ -76,7 +71,6 @@ class ParticipantDto
public array $courses = []; public array $courses = [];
public array $additionalServices = []; public array $additionalServices = [];
#[Assert\NotNull(message: 'Bitte auswählen', groups: ['booking_edit', 'booking_create'])]
public ?Service $skiPass = null; public ?Service $skiPass = null;
public array $board = []; public array $board = [];
+2
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Form\Model; namespace App\Form\Model;
use App\Validator\Constraints as AppAssert;
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;
@@ -15,6 +16,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
* the participant being edited and the full booking context needed for * the participant being edited and the full booking context needed for
* cross-participant validation. * cross-participant validation.
*/ */
#[AppAssert\Booking(groups: ['booking_edit', 'booking_create'])]
class ParticipantEditDto class ParticipantEditDto
{ {
public function __construct( public function __construct(
@@ -10,38 +10,44 @@ use App\Form\Service\Contract\FieldConditionInterface;
/** /**
* Condition that determines if personal data fields should be hidden for authenticated users. * Condition that determines if personal data fields should be hidden for authenticated users.
* *
* When a participant is linked to an existing BPN account (has personId), * ⚠️ IMPORTANT: This condition is used in CREATE MODE ONLY to protect authenticated
* their personal data should not be editable during the booking process. Changes to * applicant data during booking creation. It is NOT used in edit mode.
* master personal data should only happen through the dedicated personal data management
* interface to prevent:
* - Creating duplicate customer records in BPN
* - Disconnecting bookings from the user's account
* - Data inconsistencies between booking and account data
* *
* Note: In edit mode, the BPN API may not return addressId in booking responses, * In CREATE mode:
* so we rely on personId alone to identify authenticated users. The personId is * - When a logged-in user creates a booking, their personal data is prepopulated from
* sufficient to link a participant to an existing BPN account. * their BPN account (including personId).
* - Their personal data fields should be hidden and displayed as static text to prevent
* modifications that could:
* - Create duplicate customer records in BPN
* - Disconnect bookings from the user's account
* - Cause data inconsistencies between booking and account data
*
* In EDIT mode:
* - DO NOT use this condition. Edit mode uses PersonalDataMutabilityCondition instead,
* which respects the BPN API's per-participant `mutable` flag (aenderungmoeglich).
* - The mutability flag determines editability for ALL participants uniformly in edit mode.
* *
* When this condition is satisfied (returns true), the template should: * When this condition is satisfied (returns true), the template should:
* - Hide the form fields for personal data * - Hide the form fields for personal data
* - Display the values as static, read-only text * - Display the values as static, read-only text via the field_or_static macro
*
* This follows the same pattern as bulk insurance booking.
*/ */
class AuthenticatedUserPersonalDataCondition implements FieldConditionInterface class AuthenticatedUserPersonalDataCondition implements FieldConditionInterface
{ {
/** /**
* Evaluates if personal data fields should be hidden (not editable). * Evaluates if personal data fields should be hidden (not editable) in CREATE mode.
* *
* Returns true when the participant has both addressId and personId set, * Returns true when the participant has a personId set, indicating they are
* indicating they are linked to an existing BPN account. When true, * a logged-in user whose data was prepopulated from their BPN account.
* personal data fields should be hidden and displayed as static text. * When true, personal data fields should be hidden and displayed as static text.
* *
* @param BookingDto $bookingDto The current booking data * This condition is only used during booking creation, not in edit mode.
* Edit mode uses PersonalDataMutabilityCondition to respect BPN's mutability flag.
*
* @param BookingDto $bookingDto The current booking data (create flow)
* @param int $participantIndex The index of the participant being evaluated * @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data (unused) * @param array<string, mixed> $formData Current form data (unused)
* *
* @return bool True if fields should be hidden (participant linked to BPN account) * @return bool True if fields should be hidden (authenticated user in create mode)
*/ */
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
{ {
@@ -50,17 +56,16 @@ class AuthenticatedUserPersonalDataCondition implements FieldConditionInterface
return false; return false;
} }
// Hide personal data fields if participant has BPN person ID // Hide personal data fields if participant has BPN person ID (authenticated user in create mode)
// In edit mode, bookings may not include addressId, so we check personId only // The personId is set during prepopulation when a logged-in user starts creating a booking
// The personId alone is sufficient to identify a participant linked to a BPN account
return null !== $participant->personId; return null !== $participant->personId;
} }
/** /**
* Returns field names that this condition depends on. * Returns field names that this condition depends on.
* *
* This condition is based on addressId/personId which are set during prepopulation * This condition is based on personId which is set during prepopulation
* and don't change during the form interaction, so no field dependencies. * and doesn't change during form interaction, so no field dependencies.
* *
* @return string[] Empty array - no field dependencies * @return string[] Empty array - no field dependencies
*/ */
@@ -76,6 +81,6 @@ class AuthenticatedUserPersonalDataCondition implements FieldConditionInterface
*/ */
public function getDescription(): string public function getDescription(): string
{ {
return 'Personal data is not editable when participant is linked to BPN account (prevents duplicate records)'; return 'Personal data is not editable for authenticated users in create mode (prevents duplicate records)';
} }
} }
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Form\Service\Condition;
use App\Form\Model\BookingDto;
use App\Form\Service\Contract\FieldConditionInterface;
/**
* Condition that determines if personal data fields should be hidden based on BPN mutability flag.
*
* This condition is used in EDIT MODE ONLY to respect the BPN API's per-participant
* mutability flag (`aenderungmoeglich`). When a participant's data is not mutable
* (mutable=false), personal data fields should be hidden and displayed as static text.
*
* This differs from AuthenticatedUserPersonalDataCondition which is used in CREATE MODE
* to protect authenticated applicant data.
*
* In edit mode, BPN determines mutability based on business rules (e.g., payment status,
* booking state, etc.). We must respect this flag to prevent users from attempting to
* modify data that BPN will reject.
*
* When this condition is satisfied (returns true), the template renders fields as
* static text via the `field_or_static` macro instead of form inputs.
*/
class PersonalDataMutabilityCondition implements FieldConditionInterface
{
/**
* Evaluates if personal data fields should be hidden (not editable).
*
* Returns true when the participant's mutable flag is false, indicating
* that BPN does not allow modifications to this participant's data.
* When true, personal data fields should be hidden and displayed as static text.
*
* @param BookingDto $bookingDto The current booking data
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data (unused)
*
* @return bool True if fields should be hidden (participant data not mutable)
*/
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
{
$participant = $bookingDto->getParticipant($participantIndex);
if (null === $participant) {
return false;
}
// Hide personal data fields if BPN indicates participant is not mutable
return false === $participant->mutable;
}
/**
* Returns field names that this condition depends on.
*
* This condition is based on the mutable flag which is set from the BPN API
* and doesn't change during form interaction, so no field dependencies.
*
* @return string[] Empty array - no field dependencies
*/
public function getDependentFields(): array
{
return [];
}
/**
* Returns a human-readable description of this condition.
*
* @return string Description of the mutability check logic
*/
public function getDescription(): string
{
return 'Personal data is not editable when BPN mutability flag is false (edit mode only)';
}
}
+22 -20
View File
@@ -12,7 +12,7 @@ use App\Form\Service\Condition\AuthenticatedUserPersonalDataCondition;
use App\Form\Service\Condition\CompositeCondition; use App\Form\Service\Condition\CompositeCondition;
use App\Form\Service\Condition\DateOfBirthProvidedCondition; use App\Form\Service\Condition\DateOfBirthProvidedCondition;
use App\Form\Service\Condition\FieldValueCondition; use App\Form\Service\Condition\FieldValueCondition;
use App\Form\Service\Condition\MutabilityCondition; use App\Form\Service\Condition\PersonalDataMutabilityCondition;
use App\Form\Service\Condition\PickupsMutabilityCondition; use App\Form\Service\Condition\PickupsMutabilityCondition;
use App\Form\Service\Condition\RentalSelectionCondition; use App\Form\Service\Condition\RentalSelectionCondition;
use App\Form\Service\Condition\ServiceSubTypeCondition; use App\Form\Service\Condition\ServiceSubTypeCondition;
@@ -44,13 +44,21 @@ class EditFieldStateProvider extends AbstractFieldStateProvider
$transportationServicesMutabilityCondition = new TransportationServicesMutabilityCondition(); $transportationServicesMutabilityCondition = new TransportationServicesMutabilityCondition();
$pickupsMutabilityCondition = new PickupsMutabilityCondition(); $pickupsMutabilityCondition = new PickupsMutabilityCondition();
// Authenticated user personal data protection // Personal data protection in edit mode has TWO rules:
// Render personal data fields as static text for participants linked to BPN accounts (prevents duplicate records) // 1. Applicant with personId (authenticated user) - prevents editing master personal data
// Only applies to applicant (index 0), not other participants with personId
// 2. BPN mutability flag (mutable=false) - respects BPN business rules for any participant
// Hide fields if EITHER condition is true
$applicantCondition = new ApplicantCondition();
$authenticatedUserCondition = new AuthenticatedUserPersonalDataCondition(); $authenticatedUserCondition = new AuthenticatedUserPersonalDataCondition();
$personalDataMutabilityCondition = new PersonalDataMutabilityCondition();
$personalDataHiddenCondition = CompositeCondition::or(
CompositeCondition::and($applicantCondition, $authenticatedUserCondition),
$personalDataMutabilityCondition
);
// Make all personal data fields readonly if participant not mutable // Render all personal data fields as static text if authenticated user OR BPN indicates not mutable
// OR static text if participant is linked to BPN account (authenticated user) // Using 'static_text' state (not 'hidden') so template renders values as static text
// Note: First participant is now treated as independent from applicant and can be edited
$personalDataFields = [ $personalDataFields = [
'firstName', 'firstName',
'lastName', 'lastName',
@@ -62,36 +70,30 @@ class EditFieldStateProvider extends AbstractFieldStateProvider
]; ];
foreach ($personalDataFields as $field) { foreach ($personalDataFields as $field) {
$this->fieldStateConditions[$field] = [ $this->fieldStateConditions[$field] = [
'static_text' => $authenticatedUserCondition, 'static_text' => $personalDataHiddenCondition,
'readonly' => CompositeCondition::not(new MutabilityCondition()),
]; ];
} }
// Address fields - static text for authenticated users, readonly if participant not mutable // Address fields - render as static text if authenticated user OR participant not mutable
$this->fieldStateConditions['address'] = [ $this->fieldStateConditions['address'] = [
'static_text' => $authenticatedUserCondition, 'static_text' => $personalDataHiddenCondition,
'readonly' => CompositeCondition::not(new MutabilityCondition()),
]; ];
// Address subfields - must render as static text to prevent creating duplicate BPN records // Address subfields - render as static text if authenticated user OR participant not mutable
$this->fieldStateConditions['address.street'] = [ $this->fieldStateConditions['address.street'] = [
'static_text' => $authenticatedUserCondition, 'static_text' => $personalDataHiddenCondition,
]; ];
$this->fieldStateConditions['address.postCode'] = [ $this->fieldStateConditions['address.postCode'] = [
'static_text' => $authenticatedUserCondition, 'static_text' => $personalDataHiddenCondition,
]; ];
$this->fieldStateConditions['address.city'] = [ $this->fieldStateConditions['address.city'] = [
'static_text' => $authenticatedUserCondition, 'static_text' => $personalDataHiddenCondition,
]; ];
$this->fieldStateConditions['address.country'] = [ $this->fieldStateConditions['address.country'] = [
'static_text' => $authenticatedUserCondition, 'static_text' => $personalDataHiddenCondition,
];
$this->fieldStateConditions['address.district'] = [
'static_text' => $authenticatedUserCondition,
]; ];
// Conditional visibility for service fields (same as create flow) // Conditional visibility for service fields (same as create flow)
@@ -490,6 +490,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
'multiple' => false, 'multiple' => false,
'expanded' => true, 'expanded' => true,
'required' => false, 'required' => false,
'placeholder' => false, // Disable default placeholder - synthetic "keine Versicherung gewünscht" option injected instead
'choices' => $this->getEligibleInsurances($bookingDto, $participantIndex), 'choices' => $this->getEligibleInsurances($bookingDto, $participantIndex),
'choice_value' => 'id', 'choice_value' => 'id',
'choice_label' => function (?Insurance $insurance) { 'choice_label' => function (?Insurance $insurance) {
@@ -501,7 +502,6 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
return $label; return $label;
}, },
'placeholder' => 'Keine Versicherung gewünscht',
]; ];
// Future field providers would be added here, for example: // Future field providers would be added here, for example:
@@ -799,6 +799,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
/** /**
* Gets eligible insurances for a participant based on eligibility criteria. * Gets eligible insurances for a participant based on eligibility criteria.
* *
* Injects a synthetic "keine Versicherung gewünscht" option at the top of the list
* to force explicit user choice for legal compliance.
*
* @param BookingDto $bookingDto The booking DTO containing travel and participant data * @param BookingDto $bookingDto The booking DTO containing travel and participant data
* @param int $participantIndex The index of the participant to get eligible insurances for * @param int $participantIndex The index of the participant to get eligible insurances for
* *
@@ -818,12 +821,24 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$travelPrice = $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $participantIndex); $travelPrice = $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $participantIndex);
// Filter based on eligibility criteria for this participant // Filter based on eligibility criteria for this participant
return $this->insuranceService->getEligibleInsurances( $eligibleInsurances = $this->insuranceService->getEligibleInsurances(
$selectableInsurances, $selectableInsurances,
$participant, $participant,
$bookingDto, $bookingDto,
$travelPrice $travelPrice
); );
// Inject synthetic "no insurance" option at the top of the list
// This forces explicit user choice for legal compliance
$noInsurance = new Insurance();
$noInsurance->id = Insurance::NO_INSURANCE_ID;
$noInsurance->label = 'keine Versicherung gewünscht';
$noInsurance->price = 0.0;
// Prepend to list (appears first in radio buttons)
array_unshift($eligibleInsurances, $noInsurance);
return $eligibleInsurances;
} }
/** /**
@@ -145,6 +145,19 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
// Handle explicit new insurance selection from user // Handle explicit new insurance selection from user
if ($isNewSelection) { if ($isNewSelection) {
// Handle synthetic "no insurance" option
if (Insurance::NO_INSURANCE_ID === $selectedInsuranceId) {
// Create synthetic insurance object to satisfy validation
// This will be excluded from BPN XML transmission
$noInsurance = new Insurance();
$noInsurance->id = Insurance::NO_INSURANCE_ID;
$noInsurance->label = 'keine Versicherung gewünscht';
$noInsurance->price = 0.0;
$participant->insurance = $noInsurance;
return; // Skip all other processing for "no insurance"
}
$selectedInsurance = $this->findInsuranceById($selectableInsurances, $selectedInsuranceId); $selectedInsurance = $this->findInsuranceById($selectableInsurances, $selectedInsuranceId);
if (null !== $selectedInsurance) { if (null !== $selectedInsurance) {
-15
View File
@@ -586,21 +586,6 @@ class TravelDataService
} }
} }
/**
* Fetch availability data with short-term caching.
*
* @deprecated Use getAvailabilityData($dateId, cached: true) instead
*
* @param int $dateId The travel date ID for API call
* @param int $ttl Cache TTL in seconds (default: 60 seconds)
*
* @return BaseData|null The availability data or null if not available or error occurred
*/
public function getAvailabilityDataCached(int $dateId, int $ttl = 60): ?BaseData
{
return $this->getAvailabilityData($dateId, cached: true, ttl: $ttl);
}
/** /**
* Apply availability data to travel services. * Apply availability data to travel services.
* *
+1
View File
@@ -69,6 +69,7 @@ class AppRuntime implements RuntimeExtensionInterface
'S' => 'Stornierung', 'S' => 'Stornierung',
'O' => 'Option', 'O' => 'Option',
'U' => 'Umbuchung', 'U' => 'Umbuchung',
'A' => 'Anfrage',
default => '', default => '',
}; };
} }
@@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
#[\Attribute]
class ApplicantAddress extends Constraint
{
public function getTargets(): string
{
return self::CLASS_CONSTRAINT;
}
}
@@ -1,65 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Validator\Constraints;
use App\Form\Model\ParticipantDto;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
class ApplicantAddressValidator extends ConstraintValidator
{
public function validate(mixed $value, Constraint $constraint): void
{
/** @var ParticipantDto $participant */
$participant = $value;
// Only validate for the first participant (applicant in create mode)
if (false === $participant->isApplicant()) {
return;
}
// Ensure address object exists
if (null === $participant->address) {
return;
}
// Validate required address fields
if (null === $participant->address->street || '' === trim($participant->address->street)) {
$this->context->buildViolation('Bitte angeben')
->atPath('address.street')
->addViolation()
;
}
if (null === $participant->address->postCode || '' === trim($participant->address->postCode)) {
$this->context->buildViolation('Bitte angeben')
->atPath('address.postCode')
->addViolation()
;
}
if (null === $participant->address->city || '' === trim($participant->address->city)) {
$this->context->buildViolation('Bitte angeben')
->atPath('address.city')
->addViolation()
;
}
if (null === $participant->address->country || '' === trim($participant->address->country)) {
$this->context->buildViolation('Bitte angeben')
->atPath('address.country')
->addViolation()
;
}
// Mobile/phone is mandatory for applicant in create mode
if (null === $participant->mobile || '' === trim($participant->mobile)) {
$this->context->buildViolation('Bitte angeben')
->atPath('mobile')
->addViolation()
;
}
}
}
+12 -2
View File
@@ -1,14 +1,24 @@
<?php <?php
declare(strict_types=1);
namespace App\Validator\Constraints; namespace App\Validator\Constraints;
use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraint;
/**
* Validates required fields based on booking mode and applicant mutability.
*
* This constraint implements mode-aware validation that differentiates between:
* - Create mode: All fields mandatory
* - Edit mode (immutable): All fields mandatory
* - Edit mode (mutable): Only format validation, fields optional
*
* The applicant's (index 0) mutability flag controls validation for the entire booking.
*/
#[\Attribute] #[\Attribute]
class Booking extends Constraint class Booking extends Constraint
{ {
public string $message = 'Bitte prüfe deine Angaben.';
public function getTargets(): array|string public function getTargets(): array|string
{ {
return static::CLASS_CONSTRAINT; return static::CLASS_CONSTRAINT;
+150 -22
View File
@@ -1,39 +1,167 @@
<?php <?php
declare(strict_types=1);
namespace App\Validator\Constraints; namespace App\Validator\Constraints;
use App\BusProNet\ApiClient;
use App\BusProNet\Model\BookingUpdate;
use App\BusProNet\Model\Notification;
use App\Form\Model\BookingDto; use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Model\ParticipantEditDto;
use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator; use Symfony\Component\Validator\ConstraintValidator;
/**
* Validates required fields with mode-aware and mutability-aware rules.
*
* Validation Strategy:
* - Create mode: STRICT (all fields required + format validation)
* - Edit mode + applicant immutable: STRICT (all fields required + format validation)
* - Edit mode + applicant mutable: RELAXED (only format validation, fields optional)
*
* The applicant's (index 0) mutability flag determines validation strictness for ALL participants.
* This ensures consistent validation across the entire booking based on whether the booking
* can be modified freely or has restrictions imposed by the booking system.
*
* Strict Validation (Create + Edit Immutable):
* - Personal data: firstName, lastName, dateOfBirth, email, address (all subfields) - REQUIRED
* - Services: skiPass, transportationOutbound, transportationInbound, insurance - REQUIRED
* - Format constraints (Email, date formats) also apply
*
* Relaxed Validation (Edit Mutable):
* - All fields OPTIONAL (can be null/empty)
* - Format constraints still apply IF values are provided
* - Allows partial updates without forcing complete data re-entry
*/
class BookingValidator extends ConstraintValidator class BookingValidator extends ConstraintValidator
{ {
public function __construct(private readonly ApiClient $apiClient)
{
}
public function validate(mixed $value, Constraint $constraint): void public function validate(mixed $value, Constraint $constraint): void
{ {
/** @var BookingDto $booking */ if (!$value instanceof ParticipantEditDto) {
$bookingData = $value; return;
$result = $this->apiClient->updateBooking($bookingData);
if ($result instanceof Notification) {
$this->context
->buildViolation($result->message)
->addViolation()
;
} }
if ($result instanceof BookingUpdate && false === $result->valid) { $participant = $value->participant;
$this->context $bookingContext = $value->bookingContext;
->buildViolation($constraint->message)
->addViolation() // Determine if strict validation applies
; if (true === $this->shouldApplyStrictValidation($bookingContext)) {
$this->enforceStrictValidation($participant);
}
// Relaxed validation: no required checks, format validation handled by existing constraints
}
/**
* Determines whether strict validation should be applied.
*
* Strict validation applies when:
* 1. In create mode (new booking), OR
* 2. In edit mode AND applicant is not mutable (booking has restrictions)
*
* @param BookingDto $bookingContext The booking context
*
* @return bool True if strict validation should apply
*/
private function shouldApplyStrictValidation(BookingDto $bookingContext): bool
{
$mode = $bookingContext->getMode();
// Create mode: always strict
if (BookingDto::MODE_CREATE === $mode) {
return true;
}
// Edit mode: check applicant mutability
$applicant = $bookingContext->participants[0] ?? null;
if (null === $applicant) {
return true; // Fail-safe: if no applicant, apply strict validation
}
// Strict validation if applicant is not mutable
return false === $applicant->mutable;
}
/**
* Enforces strict validation: all required fields must be filled.
*
* Validates:
* - Personal data: firstName, lastName, dateOfBirth, email
* - Address: street, postCode, city, country
* - Services: skiPass, transportationOutbound, transportationInbound, insurance
*
* @param ParticipantDto $participant The participant to validate
*/
private function enforceStrictValidation(ParticipantDto $participant): void
{
// Skip validation for canceled participants
if (true === $participant->isCanceled()) {
return;
}
// Personal data validation
$this->validateRequired($participant->firstName, 'participant.firstName', 'Bitte angeben');
$this->validateRequired($participant->lastName, 'participant.lastName', 'Bitte angeben');
$this->validateRequired($participant->email, 'participant.email', 'Bitte angeben');
if (null === $participant->dateOfBirth) {
$this->context->buildViolation('Bitte angeben')
->atPath('participant.dateOfBirth')
->addViolation();
}
// Address validation only for applicant (index 0)
if (0 === $participant->index) {
if (null !== $participant->address) {
$this->validateRequired($participant->address->street, 'participant.address.street', 'Bitte angeben');
$this->validateRequired($participant->address->postCode, 'participant.address.postCode', 'Bitte angeben');
$this->validateRequired($participant->address->city, 'participant.address.city', 'Bitte angeben');
$this->validateRequired($participant->address->country, 'participant.address.country', 'Bitte angeben');
} else {
$this->context->buildViolation('Bitte angeben')
->atPath('participant.address')
->addViolation();
}
}
// Service validation
if (null === $participant->skiPass) {
$this->context->buildViolation('Bitte auswählen')
->atPath('participant.skiPass')
->addViolation();
}
if (null === $participant->transportationOutbound) {
$this->context->buildViolation('Bitte auswählen')
->atPath('participant.transportationOutbound')
->addViolation();
}
if (null === $participant->transportationInbound) {
$this->context->buildViolation('Bitte auswählen')
->atPath('participant.transportationInbound')
->addViolation();
}
if (null === $participant->insurance) {
$this->context->buildViolation('Bitte auswählen')
->atPath('participant.insurance')
->addViolation();
}
}
/**
* Validates that a field is not empty (null or blank string).
*
* @param mixed $value The field value to check
* @param string $path The property path for the violation
* @param string $message The violation message
*/
private function validateRequired(mixed $value, string $path, string $message): void
{
if (null === $value || '' === trim((string) $value)) {
$this->context->buildViolation($message)
->atPath($path)
->addViolation();
} }
} }
} }
@@ -14,7 +14,6 @@ class ParticipantValidator extends ConstraintValidator
$participant = $value; $participant = $value;
$this->assertBodyMeasurementsValid($participant); $this->assertBodyMeasurementsValid($participant);
$this->assertTransportationSelected($participant);
$this->assertPickupSelected($participant); $this->assertPickupSelected($participant);
} }
@@ -34,23 +33,6 @@ class ParticipantValidator extends ConstraintValidator
} }
} }
public function assertTransportationSelected(ParticipantDto $participant): void
{
// no transportation services required for canceled participants
if (true === $participant->isCanceled()) {
return;
}
foreach (['transportationOutbound', 'transportationInbound'] as $property) {
if (null === $participant->{$property}) {
$this->context->buildViolation('Bitte angeben')
->atPath($property)
->addViolation()
;
}
}
}
public function assertPickupSelected(ParticipantDto $participant): void public function assertPickupSelected(ParticipantDto $participant): void
{ {
// Check if either outbound or inbound transportation is bus // Check if either outbound or inbound transportation is bus
+5
View File
@@ -0,0 +1,5 @@
<div class="mt-8 pt-4 border-t border-gray-200 text-center">
<a href="{{ path('app_booking_cancel') }}" class="text-sm text-gray-600 hover:text-gray-900 hover:underline">
Buchung abbrechen und zur Startseite zurückkehren
</a>
</div>
@@ -100,4 +100,6 @@
</div> </div>
</div> </div>
</div> </div>
{% include 'booking/_cancel_link.html.twig' %}
{% endblock %} {% endblock %}
@@ -75,4 +75,6 @@
</div> </div>
{% endblock %} {% endblock %}
</div> </div>
{% include 'booking/_cancel_link.html.twig' %}
{% endblock %} {% endblock %}
@@ -72,4 +72,6 @@
</div> </div>
{% endblock %} {% endblock %}
</div> </div>
{% include 'booking/_cancel_link.html.twig' %}
{% endblock %} {% endblock %}
@@ -65,4 +65,6 @@
} %} } %}
</div> </div>
</div> </div>
{% include 'booking/_cancel_link.html.twig' %}
{% endblock %} {% endblock %}
@@ -426,4 +426,6 @@
{{ form_end(form) }} {{ form_end(form) }}
</div> </div>
{% include 'booking/_cancel_link.html.twig' %}
{% endblock %} {% endblock %}
+26 -1
View File
@@ -253,11 +253,24 @@ class ParticipantEditDtoTest extends TestCase
$bookingDto = new BookingDto($travel, 1); // hotelId must be int $bookingDto = new BookingDto($travel, 1); // hotelId must be int
// Create mock booking to simulate edit mode
$mockBooking = new \App\BusProNet\Model\Booking();
$bookingDto->booking = $mockBooking;
// Create valid participants with all required fields for strict validation
$participant1 = $this->createAdultParticipant('[email protected]'); $participant1 = $this->createAdultParticipant('[email protected]');
$participant1->index = 0; $participant1->index = 0;
$participant1->mutable = false; // Immutable - strict validation applies
$participant1->mobile = '+49 123 456789'; // Required for applicant
$participant1->address = new \App\BusProNet\Model\Address();
$participant1->address->street = 'Test Street 1';
$participant1->address->postCode = '12345';
$participant1->address->city = 'Test City';
$participant1->address->country = 'DE';
$participant2 = $this->createAdultParticipant('[email protected]'); $participant2 = $this->createAdultParticipant('[email protected]');
$participant2->index = 1; $participant2->index = 1;
$participant2->mutable = false;
$bookingDto->participants = [$participant1, $participant2]; $bookingDto->participants = [$participant1, $participant2];
@@ -268,7 +281,7 @@ class ParticipantEditDtoTest extends TestCase
$violations = $this->validator->validate($wrapper, null, ['booking_edit']); $violations = $this->validator->validate($wrapper, null, ['booking_edit']);
// Should have uniqueness violation in edit mode as well // Should have uniqueness violation (edit mode with immutable applicant = strict validation)
$this->assertCount(1, $violations); $this->assertCount(1, $violations);
} }
@@ -357,6 +370,7 @@ class ParticipantEditDtoTest extends TestCase
$participant->skiPass = $this->createMockService(); $participant->skiPass = $this->createMockService();
$participant->transportationOutbound = $this->createMockService(); $participant->transportationOutbound = $this->createMockService();
$participant->transportationInbound = $this->createMockService(); $participant->transportationInbound = $this->createMockService();
$participant->insurance = $this->createMockInsurance();
return $participant; return $participant;
} }
@@ -379,6 +393,7 @@ class ParticipantEditDtoTest extends TestCase
$participant->skiPass = $this->createMockService(); $participant->skiPass = $this->createMockService();
$participant->transportationOutbound = $this->createMockService(); $participant->transportationOutbound = $this->createMockService();
$participant->transportationInbound = $this->createMockService(); $participant->transportationInbound = $this->createMockService();
$participant->insurance = $this->createMockInsurance();
return $participant; return $participant;
} }
@@ -404,4 +419,14 @@ class ParticipantEditDtoTest extends TestCase
return $service; return $service;
} }
private function createMockInsurance(): \App\BusProNet\Model\Insurance
{
$insurance = new \App\BusProNet\Model\Insurance();
$insurance->id = '1';
$insurance->label = 'Test Insurance';
$insurance->price = 10.0;
return $insurance;
}
} }
@@ -77,7 +77,7 @@ class ParticipantValidatorTest extends ConstraintValidatorTestCase
$this->assertNoViolation(); $this->assertNoViolation();
} }
public function testCanceledParticipantSkipsTransportationValidation(): void public function testCanceledParticipantSkipsValidation(): void
{ {
$participant = $this->createValidParticipant(); $participant = $this->createValidParticipant();
$participant->status = 'S'; // Canceled $participant->status = 'S'; // Canceled
@@ -89,22 +89,6 @@ class ParticipantValidatorTest extends ConstraintValidatorTestCase
$this->assertNoViolation(); $this->assertNoViolation();
} }
public function testActiveParticipantWithoutTransportationFailsValidation(): void
{
$participant = $this->createValidParticipant();
$participant->status = 'F'; // Active
$participant->transportationOutbound = null;
$participant->transportationInbound = null;
$this->validator->validate($participant, new Participant());
$this->buildViolation('Bitte angeben')
->atPath('property.path.transportationOutbound')
->buildNextViolation('Bitte angeben')
->atPath('property.path.transportationInbound')
->assertRaised();
}
public function testParticipantWithBusTransportationButNoPickupFailsValidation(): void public function testParticipantWithBusTransportationButNoPickupFailsValidation(): void
{ {
$participant = $this->createValidParticipant(); $participant = $this->createValidParticipant();