From 388ecf96030de6c9b0a838c2bfab82ef3862b2c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Mon, 10 Aug 2026 18:06:25 +0200 Subject: [PATCH] feat: financial data check for teamers, improved enforced checks logic addresses #869at5rtp --- .env | 2 - config/services.yaml | 14 +- .../Teamer/Check/FinancialDataController.php | 62 +++++++ .../Teamer/Check/PersonalDataController.php | 65 +++++++ .../Teamer/Profile/IndexController.php | 13 +- .../RequiredTeamerCheckSubscriber.php | 4 + src/Form/FinancialDataType.php | 40 +++++ src/Form/PersonalDataConfirmationType.php | 26 +++ src/Form/TeamerProfileType.php | 17 -- src/Model/FinancialDataDto.php | 119 +++++++++++++ src/Model/PersonalDataConfirmationDto.php | 23 +++ .../FinancialDataRequiredCheck.php | 55 ++++++ .../PersonalDataVerificationRequiredCheck.php | 84 ++------- .../RecurringDeadlines.php | 104 +++++++++++ .../teamer/check/financial_data.html.twig | 40 +++++ .../teamer/check/personal_data.html.twig | 84 +++++++++ templates/teamer/profile/index.html.twig | 11 +- tests/Form/FinancialDataTypeTest.php | 166 ++++++++++++++++++ .../Form/PersonalDataConfirmationTypeTest.php | 84 +++++++++ .../FinancialDataRequiredCheckTest.php | 136 ++++++++++++++ ...sonalDataVerificationRequiredCheckTest.php | 117 ++++-------- .../RequiredCheck/RecurringDeadlinesTest.php | 125 +++++++++++++ 22 files changed, 1196 insertions(+), 195 deletions(-) create mode 100644 src/Controller/Teamer/Check/FinancialDataController.php create mode 100644 src/Controller/Teamer/Check/PersonalDataController.php create mode 100644 src/Form/FinancialDataType.php create mode 100644 src/Form/PersonalDataConfirmationType.php create mode 100644 src/Model/FinancialDataDto.php create mode 100644 src/Model/PersonalDataConfirmationDto.php create mode 100644 src/RequiredTeamerCheck/FinancialDataRequiredCheck.php create mode 100644 src/RequiredTeamerCheck/RecurringDeadlines.php create mode 100644 templates/teamer/check/financial_data.html.twig create mode 100644 templates/teamer/check/personal_data.html.twig create mode 100644 tests/Form/FinancialDataTypeTest.php create mode 100644 tests/Form/PersonalDataConfirmationTypeTest.php create mode 100644 tests/Security/RequiredCheck/FinancialDataRequiredCheckTest.php create mode 100644 tests/Security/RequiredCheck/RecurringDeadlinesTest.php diff --git a/.env b/.env index 74f1b00..76c46ee 100644 --- a/.env +++ b/.env @@ -75,5 +75,3 @@ FEATURE_SANITIZE_UPLOADS=false FEATURE_STAMP_INVOICES=false UPLOAD_REPLACEMENT_FILE=assets/pdf/chicken.pdf - -PERSONAL_DATA_CHECK_DEADLINES=04-01,10-01 diff --git a/config/services.yaml b/config/services.yaml index 623d0d1..b9d6968 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -9,6 +9,11 @@ parameters: teamer_inactive_period: '-2 years' + # Dates (MM-DD) on which teamers must re-confirm their personal data. + personal_data_check_deadlines: + - '04-01' + - '10-01' + # Houses, hotel code: name. The name is used both as a label and to match # destination.hotel, the code to match destination.hotelCode. houses: @@ -175,13 +180,20 @@ services: App\RequiredTeamerCheck\RequiredTeamerCheckRegistry: arguments: + # Presentation order only. Each check must point at a page that clears + # that check alone, so a teamer can always work through them whatever + # the order — never make correctness depend on this list. $checks: - '@App\RequiredTeamerCheck\DriverLicenseRequiredCheck' + - '@App\RequiredTeamerCheck\FinancialDataRequiredCheck' - '@App\RequiredTeamerCheck\PersonalDataVerificationRequiredCheck' App\RequiredTeamerCheck\PersonalDataVerificationRequiredCheck: arguments: - $dataVerificationDeadlines: '%env(csv:PERSONAL_DATA_CHECK_DEADLINES)%' + $deadlines: !service + class: App\RequiredTeamerCheck\RecurringDeadlines + arguments: + $deadlines: '%personal_data_check_deadlines%' app.upload_namer: class: App\Service\Upload\UploadNamer diff --git a/src/Controller/Teamer/Check/FinancialDataController.php b/src/Controller/Teamer/Check/FinancialDataController.php new file mode 100644 index 0000000..f397993 --- /dev/null +++ b/src/Controller/Teamer/Check/FinancialDataController.php @@ -0,0 +1,62 @@ +getUser(); + $teamer = $user->getTeamer(); + + if (null === $teamer) { + $teamer = new Teamer(); + $user->setTeamer($teamer); + $this->entityManager->persist($teamer); + $this->entityManager->flush(); + } + + $formData = FinancialDataDto::fromTeamer($teamer); + $form = $this->createForm(FinancialDataType::class, $formData); + $form->handleRequest($request); + + if (true === $form->isSubmitted() && true === $form->isValid()) { + $formData->applyTo($teamer); + $this->entityManager->flush(); + + $this->addFlash('success', 'Deine Angaben wurden gespeichert.'); + $this->logger->info('Submit teamer financial data', [ + 'teamer_id' => $teamer->getId(), + 'teamer_name' => (string) $teamer, + ]); + + return $this->redirectToRoute('app_teamer_index'); + } + + return $this->render('teamer/check/financial_data.html.twig', [ + 'form' => $form->createView(), + 'teamer' => $teamer, + ]); + } +} diff --git a/src/Controller/Teamer/Check/PersonalDataController.php b/src/Controller/Teamer/Check/PersonalDataController.php new file mode 100644 index 0000000..4ace5bb --- /dev/null +++ b/src/Controller/Teamer/Check/PersonalDataController.php @@ -0,0 +1,65 @@ +getUser(); + $teamer = $user->getTeamer(); + + if (null === $teamer) { + $teamer = new Teamer(); + $user->setTeamer($teamer); + $this->entityManager->persist($teamer); + $this->entityManager->flush(); + } + + $form = $this->createForm(PersonalDataConfirmationType::class, new PersonalDataConfirmationDto()); + $form->handleRequest($request); + + if (true === $form->isSubmitted() && true === $form->isValid()) { + $teamer->setDataVerifiedAt(new \DateTimeImmutable()); + $this->entityManager->flush(); + + $this->addFlash('success', 'Vielen Dank, deine Daten wurden bestätigt.'); + $this->logger->info('Confirm teamer personal data', [ + 'teamer_id' => $teamer->getId(), + 'teamer_name' => (string) $teamer, + ]); + + return $this->redirectToRoute('app_teamer_index'); + } + + return $this->render('teamer/check/personal_data.html.twig', [ + 'form' => $form->createView(), + 'teamer' => $teamer, + // surfaced as a hint only, deliberately not blocking the confirmation + 'errors' => $this->validator->validate($teamer, null, ['profile_preflight']), + ]); + } +} diff --git a/src/Controller/Teamer/Profile/IndexController.php b/src/Controller/Teamer/Profile/IndexController.php index c730c75..de1a57c 100644 --- a/src/Controller/Teamer/Profile/IndexController.php +++ b/src/Controller/Teamer/Profile/IndexController.php @@ -9,7 +9,6 @@ use App\Entity\Upload; use App\Entity\User; use App\Form\TeamerProfileType; use App\Model\UploadSessionDto; -use App\RequiredTeamerCheck\PersonalDataVerificationRequiredCheck; use App\Service\Upload\UploadHandler; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; @@ -28,7 +27,6 @@ class IndexController extends AbstractController private readonly ApiClient $apiClient, private readonly UploadHandler $uploadHandler, private readonly LoggerInterface $logger, - private readonly PersonalDataVerificationRequiredCheck $personalDataVerificationRequiredCheck, ) { } @@ -55,12 +53,8 @@ class IndexController extends AbstractController // Validate teamer data to show missing data right away $errors = $this->validator->validate($teamer, null, ['profile_preflight']); - $verificationMode = true === $this->personalDataVerificationRequiredCheck->appliesTo($user) - && false === $this->personalDataVerificationRequiredCheck->isSatisfied($user); - $form = $this->createForm(TeamerProfileType::class, $teamer, [ 'upload_session' => $uploadSession, - 'verification_mode' => $verificationMode, ]); $form->handleRequest($request); @@ -72,9 +66,9 @@ class IndexController extends AbstractController } catch (ApiClientException $e) { } - if (true === $verificationMode) { - $teamer->setDataVerifiedAt(new \DateTimeImmutable()); - } + // Saving the full profile form is a stronger statement than ticking the + // confirmation box, so it clears the verification check too. + $teamer->setDataVerifiedAt(new \DateTimeImmutable()); $this->entityManager->flush(); @@ -91,7 +85,6 @@ class IndexController extends AbstractController 'form' => $form->createView(), 'teamer' => $teamer, 'errors' => $errors, - 'verification_mode' => $verificationMode, ]); } diff --git a/src/EventListener/RequiredTeamerCheckSubscriber.php b/src/EventListener/RequiredTeamerCheckSubscriber.php index fbd112f..1988e00 100644 --- a/src/EventListener/RequiredTeamerCheckSubscriber.php +++ b/src/EventListener/RequiredTeamerCheckSubscriber.php @@ -54,6 +54,10 @@ class RequiredTeamerCheckSubscriber implements EventSubscriberInterface if (true === in_array($route, [ $check->getRouteName(), + // the profile is where data gets corrected, so it has to stay reachable + // while a check is outstanding - otherwise a teamer asked to confirm + // wrong data has no way to fix it first + 'app_teamer_profile_index', 'app_upload_delete', 'app_security_logout', ], true)) { diff --git a/src/Form/FinancialDataType.php b/src/Form/FinancialDataType.php new file mode 100644 index 0000000..c249211 --- /dev/null +++ b/src/Form/FinancialDataType.php @@ -0,0 +1,40 @@ +add('taxId', TextType::class, [ + 'label' => 'Steuer-ID', + ]) + ->add('iban', TextType::class, [ + 'label' => 'IBAN', + ]) + ->add('bic', TextType::class, [ + 'label' => 'BIC', + ]) + ->add('bank', TextType::class, [ + 'label' => 'Name der Bank', + ]) + ->add('holder', TextType::class, [ + 'label' => 'Kontoinhaber', + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => FinancialDataDto::class, + ]); + } +} diff --git a/src/Form/PersonalDataConfirmationType.php b/src/Form/PersonalDataConfirmationType.php new file mode 100644 index 0000000..8bb3e79 --- /dev/null +++ b/src/Form/PersonalDataConfirmationType.php @@ -0,0 +1,26 @@ +add('confirmed', CheckboxType::class, [ + 'label' => 'Ich bestätige, dass meine Daten korrekt und aktuell sind', + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => PersonalDataConfirmationDto::class, + ]); + } +} diff --git a/src/Form/TeamerProfileType.php b/src/Form/TeamerProfileType.php index 4c7002b..ec662f3 100644 --- a/src/Form/TeamerProfileType.php +++ b/src/Form/TeamerProfileType.php @@ -7,7 +7,6 @@ use App\Model\UploadSessionDto; use App\Service\Upload\UploadHandler; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\BirthdayType; -use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\CollectionType; use Symfony\Component\Form\Extension\Core\Type\TextType; @@ -15,7 +14,6 @@ use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormView; use Symfony\Component\OptionsResolver\OptionsResolver; -use Symfony\Component\Validator\Constraints\IsTrue; class TeamerProfileType extends AbstractType { @@ -96,19 +94,6 @@ class TeamerProfileType extends AbstractType ], ]) ; - - if (true === $options['verification_mode']) { - $builder->add('confirmDataVerification', CheckboxType::class, [ - 'label' => 'Ich bestätige, dass meine Daten korrekt und aktuell sind', - 'mapped' => false, - 'constraints' => [ - new IsTrue([ - 'message' => 'Deine Bestätigung ist erforderlich', - 'groups' => ['profile'], - ]), - ], - ]); - } } public function buildView(FormView $view, FormInterface $form, array $options): void @@ -128,11 +113,9 @@ class TeamerProfileType extends AbstractType 'validation_groups' => [ 'profile', ], - 'verification_mode' => false, ]) ->setRequired(['upload_session']) ->setAllowedTypes('upload_session', UploadSessionDto::class) - ->setAllowedTypes('verification_mode', 'bool') ; } } diff --git a/src/Model/FinancialDataDto.php b/src/Model/FinancialDataDto.php new file mode 100644 index 0000000..24de521 --- /dev/null +++ b/src/Model/FinancialDataDto.php @@ -0,0 +1,119 @@ +setTaxId($teamer->getTaxId()); + + if (null === $bankAccount = $teamer->getBankAccount()) { + return $instance; + } + + return $instance + ->setIban($bankAccount->getIban()) + ->setBic($bankAccount->getBic()) + ->setBank($bankAccount->getBank()) + ->setHolder($bankAccount->getHolder()) + ; + } + + public function applyTo(Teamer $teamer): void + { + if (null === $bankAccount = $teamer->getBankAccount()) { + $bankAccount = new BankAccount(); + $teamer->setBankAccount($bankAccount); + } + + $bankAccount + ->setIban($this->getIban()) + ->setBic($this->getBic()) + ->setBank($this->getBank()) + ->setHolder($this->getHolder()) + ; + + $teamer->setTaxId($this->getTaxId()); + } + + public function getTaxId(): ?string + { + return $this->taxId; + } + + public function setTaxId(?string $taxId): static + { + $this->taxId = $taxId; + + return $this; + } + + public function getIban(): ?string + { + return $this->iban; + } + + public function setIban(?string $iban): static + { + $this->iban = $iban; + + return $this; + } + + public function getBic(): ?string + { + return $this->bic; + } + + public function setBic(?string $bic): static + { + $this->bic = $bic; + + return $this; + } + + public function getBank(): ?string + { + return $this->bank; + } + + public function setBank(?string $bank): static + { + $this->bank = $bank; + + return $this; + } + + public function getHolder(): ?string + { + return $this->holder; + } + + public function setHolder(?string $holder): static + { + $this->holder = $holder; + + return $this; + } +} diff --git a/src/Model/PersonalDataConfirmationDto.php b/src/Model/PersonalDataConfirmationDto.php new file mode 100644 index 0000000..40d3d25 --- /dev/null +++ b/src/Model/PersonalDataConfirmationDto.php @@ -0,0 +1,23 @@ +confirmed; + } + + public function setConfirmed(?bool $confirmed): static + { + $this->confirmed = $confirmed; + + return $this; + } +} diff --git a/src/RequiredTeamerCheck/FinancialDataRequiredCheck.php b/src/RequiredTeamerCheck/FinancialDataRequiredCheck.php new file mode 100644 index 0000000..8398a2f --- /dev/null +++ b/src/RequiredTeamerCheck/FinancialDataRequiredCheck.php @@ -0,0 +1,55 @@ +getTeamer(); + + if (null === $teamer) { + return false; + } + + if (true === empty($teamer->getTaxId())) { + return false; + } + + $bankAccount = $teamer->getBankAccount(); + + if (null === $bankAccount) { + return false; + } + + if (true === empty($bankAccount->getIban())) { + return false; + } + + if (true === empty($bankAccount->getBic())) { + return false; + } + + if (true === empty($bankAccount->getBank())) { + return false; + } + + if (true === empty($bankAccount->getHolder())) { + return false; + } + + return true; + } + + public function getRouteName(): string + { + return 'app_teamer_check_financial_data'; + } +} diff --git a/src/RequiredTeamerCheck/PersonalDataVerificationRequiredCheck.php b/src/RequiredTeamerCheck/PersonalDataVerificationRequiredCheck.php index 884b60d..eee8693 100644 --- a/src/RequiredTeamerCheck/PersonalDataVerificationRequiredCheck.php +++ b/src/RequiredTeamerCheck/PersonalDataVerificationRequiredCheck.php @@ -9,7 +9,7 @@ use Carbon\CarbonImmutable; class PersonalDataVerificationRequiredCheck extends AbstractRequiredTeamerCheck { public function __construct( - private readonly array $dataVerificationDeadlines = ['04-01', '10-01'], + private readonly RecurringDeadlines $deadlines, ) { } @@ -26,84 +26,28 @@ class PersonalDataVerificationRequiredCheck extends AbstractRequiredTeamerCheck return false; } - return false === $this->isVerificationRequired($teamer); + return false === $this->deadlines->isDue( + $teamer->getDataVerifiedAt(), + $this->getRegisteredAt($teamer), + CarbonImmutable::now(), + ); } public function getRouteName(): string { - return 'app_teamer_profile_index'; - } - - private function isVerificationRequired(Teamer $teamer): bool - { - $now = CarbonImmutable::now()->startOfDay(); - $deadline = $this->getCurrentDeadline($now); - - if (null === $deadline) { - return false; - } - - if (true === $this->isRegisteredAfterAnyCurrentYearDeadline($teamer, $now)) { - return false; - } - - $dataVerifiedAt = $teamer->getDataVerifiedAt(); - - if (null === $dataVerifiedAt) { - return true; - } - - return CarbonImmutable::instance($dataVerifiedAt) < $deadline; - } - - private function isRegisteredAfterAnyCurrentYearDeadline(Teamer $teamer, CarbonImmutable $now): bool - { - try { - $registeredAt = CarbonImmutable::instance($teamer->getCreatedAt())->startOfDay(); - } catch (\TypeError) { - return false; - } - - foreach ($this->getDeadlinesForYear($now) as $deadline) { - if ($registeredAt > $deadline) { - return true; - } - } - - return false; + return 'app_teamer_check_personal_data'; } /** - * @return CarbonImmutable[] + * Teamer::getCreatedAt() is declared non-nullable but backed by a nullable + * column, so it throws for an unpersisted teamer. */ - private function getDeadlinesForYear(CarbonImmutable $now): array + private function getRegisteredAt(Teamer $teamer): ?\DateTimeImmutable { - $deadlines = []; - - foreach (array_filter(array_map('trim', $this->dataVerificationDeadlines)) as $date) { - $deadline = CarbonImmutable::createFromFormat('Y-m-d', sprintf('%s-%s', $now->format('Y'), $date)); - - if (false === $deadline instanceof CarbonImmutable) { - continue; - } - - $deadlines[] = $deadline->startOfDay(); + try { + return $teamer->getCreatedAt(); + } catch (\TypeError) { + return null; } - - return $deadlines; - } - - private function getCurrentDeadline(CarbonImmutable $now): ?CarbonImmutable - { - $deadlines = $this->getDeadlinesForYear($now); - rsort($deadlines); - - foreach ($deadlines as $deadline) { - if ($deadline <= $now) { - return $deadline; - } - } - - return null; } } diff --git a/src/RequiredTeamerCheck/RecurringDeadlines.php b/src/RequiredTeamerCheck/RecurringDeadlines.php new file mode 100644 index 0000000..983a302 --- /dev/null +++ b/src/RequiredTeamerCheck/RecurringDeadlines.php @@ -0,0 +1,104 @@ +getCurrentDeadline($now); + + if (null === $deadline) { + return false; + } + + if (true === $this->isRegisteredAfterAnyDeadline($registeredAt, $now)) { + return false; + } + + if (null === $confirmedAt) { + return true; + } + + return CarbonImmutable::instance($confirmedAt) < $deadline; + } + + /** + * The most recent deadline that has already passed, or null when none has. + */ + public function getCurrentDeadline(CarbonImmutable $now): ?CarbonImmutable + { + $deadlines = $this->getDeadlinesForYear($now); + rsort($deadlines); + + foreach ($deadlines as $deadline) { + if ($deadline <= $now) { + return $deadline; + } + } + + return null; + } + + /** + * Someone who registered after a deadline has effectively confirmed their data + * by registering, so they are exempt until the next one. + */ + private function isRegisteredAfterAnyDeadline(?\DateTimeInterface $registeredAt, CarbonImmutable $now): bool + { + if (null === $registeredAt) { + return false; + } + + $registeredAt = CarbonImmutable::instance($registeredAt)->startOfDay(); + + foreach ($this->getDeadlinesForYear($now) as $deadline) { + if ($registeredAt > $deadline) { + return true; + } + } + + return false; + } + + /** + * @return CarbonImmutable[] + */ + private function getDeadlinesForYear(CarbonImmutable $now): array + { + $deadlines = []; + + foreach (array_filter(array_map('trim', $this->deadlines)) as $date) { + $deadline = CarbonImmutable::createFromFormat('Y-m-d', sprintf('%s-%s', $now->format('Y'), $date)); + + if (false === $deadline instanceof CarbonImmutable) { + continue; + } + + $deadlines[] = $deadline->startOfDay(); + } + + return $deadlines; + } +} diff --git a/templates/teamer/check/financial_data.html.twig b/templates/teamer/check/financial_data.html.twig new file mode 100644 index 0000000..d9e626d --- /dev/null +++ b/templates/teamer/check/financial_data.html.twig @@ -0,0 +1,40 @@ +{% extends 'teamer/layout.html.twig' %} + +{% block title %}Honorardaten{% endblock %} + +{% block content %} + {{ form_start(form) }} + +
+

+ Bankverbindung und Steuer-ID +

+

+ Damit wir dein Honorar auszahlen können, benötigen wir einmalig deine Steuer-ID und deine + Bankverbindung. Bitte ergänze die folgenden Angaben: +

+ + {% if not form.vars.valid %} + {% embed '_partials/_alert.html.twig' with { 'type': 'warning' } %} + {% block message %} + {% include '_partials/_form_errors.html.twig' with { 'form': form } %} + {% endblock %} + {% endembed %} + {% endif %} + +
+ {{ form_row(form.taxId) }} + {{ form_row(form.iban) }} + {{ form_row(form.bic) }} + {{ form_row(form.bank) }} + {{ form_row(form.holder) }} +
+ + +
+ + {{ form_rest(form) }} + {{ form_end(form) }} +{% endblock %} diff --git a/templates/teamer/check/personal_data.html.twig b/templates/teamer/check/personal_data.html.twig new file mode 100644 index 0000000..97eae14 --- /dev/null +++ b/templates/teamer/check/personal_data.html.twig @@ -0,0 +1,84 @@ +{% extends 'teamer/layout.html.twig' %} + +{% block title %}Datenbestätigung{% endblock %} + +{% block content %} + {{ form_start(form) }} + +
+

+ Datenbestätigung +

+

+ Bitte prüfe, ob deine hinterlegten Daten noch aktuell sind, und bestätige sie. +

+ + {% if not form.vars.valid %} + {% embed '_partials/_alert.html.twig' with { 'type': 'warning' } %} + {% block message %} + {% include '_partials/_form_errors.html.twig' with { 'form': form } %} + {% endblock %} + {% endembed %} + {% endif %} + +
+
+
+
Name
+
{{ teamer.fullName }}
+
+
+
Geburtsdatum
+
{{ teamer.dateOfBirth ? teamer.dateOfBirth|date('d.m.Y') : '-' }}
+
+
+
Anschrift
+
+ {{ teamer.address.street|default('-') }}
+ {{ teamer.address.postCode|default('') }} {{ teamer.address.city|default('') }}
+ {{ teamer.address.country|default('') }} +
+
+
+
Kontakt
+
+ {{ teamer.communication.email|default('-') }}
+ {{ teamer.communication.mobile|default('-') }} +
+
+
+
+ + {% if errors|length %} + {% embed '_partials/_alert.html.twig' with { 'type': 'warning' } %} + {% block message %} + Einige Angaben in deinem Profil fehlen noch: + + + Im Profil ergänzen + + {% endblock %} + {% endembed %} + {% endif %} + +

+ Stimmt etwas nicht? + Daten im Profil ändern +

+ +
+ {{ form_row(form.confirmed) }} +
+ + +
+ + {{ form_rest(form) }} + {{ form_end(form) }} +{% endblock %} diff --git a/templates/teamer/profile/index.html.twig b/templates/teamer/profile/index.html.twig index 39259e9..f3271dd 100644 --- a/templates/teamer/profile/index.html.twig +++ b/templates/teamer/profile/index.html.twig @@ -53,9 +53,6 @@

Mein Profil

- {% if verification_mode %} - {% include '_partials/_alert.html.twig' with { 'message': 'Bitte überprüfe deine Daten und bestätige sie am Ende des Formulars.' } %} - {% endif %} {% if not form.vars.valid %}
{% include '_partials/_form_errors.html.twig' with { 'form': form } %} @@ -192,14 +189,8 @@ } %}
- {% if verification_mode %} -
- {{ form_row(form.confirmDataVerification) }} -
- {% endif %} - diff --git a/tests/Form/FinancialDataTypeTest.php b/tests/Form/FinancialDataTypeTest.php new file mode 100644 index 0000000..5c414dc --- /dev/null +++ b/tests/Form/FinancialDataTypeTest.php @@ -0,0 +1,166 @@ +createForm($formData); + $form->submit($this->validSubmission()); + + $this->assertTrue($form->isValid()); + + $formData->applyTo($teamer); + + $this->assertNull($teamer->getPhoto()); + $this->assertNull($teamer->getLanguage()); + $this->assertNull($teamer->getHealthInsuranceCompany()); + } + + /** + * Guards against the form and the check drifting apart: what the form accepts + * as complete must be exactly what unlocks the portal. + */ + public function testAValidSubmissionSatisfiesTheCheck(): void + { + $teamer = new Teamer(); + $user = (new User()) + ->setRoles(['ROLE_TEAMER']) + ->setTeamer($teamer) + ; + + $check = new FinancialDataRequiredCheck(); + $this->assertFalse($check->isSatisfied($user)); + + $formData = FinancialDataDto::fromTeamer($teamer); + $form = $this->createForm($formData); + $form->submit($this->validSubmission()); + $formData->applyTo($teamer); + + $this->assertTrue($check->isSatisfied($user)); + } + + public function testEmptySubmitIsRejectedOnEveryField(): void + { + $form = $this->createForm(new FinancialDataDto()); + $form->submit([ + 'taxId' => '', + 'iban' => '', + 'bic' => '', + 'bank' => '', + 'holder' => '', + ]); + + $this->assertFalse($form->isValid()); + + foreach (['taxId', 'iban', 'bic', 'bank', 'holder'] as $field) { + $this->assertCount(1, $form->get($field)->getErrors(), sprintf('Expected one error on "%s"', $field)); + } + } + + public function testMalformedIbanIsRejected(): void + { + $form = $this->createForm(new FinancialDataDto()); + $form->submit(array_merge($this->validSubmission(), ['iban' => 'DE00 not an iban'])); + + $this->assertFalse($form->isValid()); + $this->assertCount(1, $form->get('iban')->getErrors()); + $this->assertCount(0, $form->get('taxId')->getErrors()); + } + + public function testFromTeamerToleratesAMissingBankAccount(): void + { + $teamer = (new Teamer())->setTaxId('12345678901'); + + $formData = FinancialDataDto::fromTeamer($teamer); + + $this->assertSame('12345678901', $formData->getTaxId()); + $this->assertNull($formData->getIban()); + } + + public function testApplyToCreatesABankAccountWhenTheTeamerHasNone(): void + { + $teamer = new Teamer(); + $this->assertNull($teamer->getBankAccount()); + + (new FinancialDataDto()) + ->setTaxId('12345678901') + ->setIban('DE02120300000000202051') + ->setBic('BYLADEM1001') + ->setBank('Deutsche Kreditbank') + ->setHolder('Erika Mustermann') + ->applyTo($teamer) + ; + + $this->assertInstanceOf(BankAccount::class, $teamer->getBankAccount()); + $this->assertSame('DE02120300000000202051', $teamer->getBankAccount()->getIban()); + $this->assertSame('12345678901', $teamer->getTaxId()); + } + + public function testFromTeamerRoundTripsAnExistingBankAccount(): void + { + $teamer = (new Teamer()) + ->setTaxId('12345678901') + ->setBankAccount( + (new BankAccount()) + ->setIban('DE02120300000000202051') + ->setBic('BYLADEM1001') + ->setBank('Deutsche Kreditbank') + ->setHolder('Erika Mustermann') + ) + ; + + $formData = FinancialDataDto::fromTeamer($teamer); + + $this->assertSame('DE02120300000000202051', $formData->getIban()); + $this->assertSame('BYLADEM1001', $formData->getBic()); + $this->assertSame('Deutsche Kreditbank', $formData->getBank()); + $this->assertSame('Erika Mustermann', $formData->getHolder()); + } + + private function validSubmission(): array + { + return [ + 'taxId' => '12345678901', + 'iban' => 'DE02120300000000202051', + 'bic' => 'BYLADEM1001', + 'bank' => 'Deutsche Kreditbank', + 'holder' => 'Erika Mustermann', + ]; + } + + private function createForm(FinancialDataDto $formData): FormInterface + { + self::bootKernel(); + + /** @var FormFactoryInterface $formFactory */ + $formFactory = self::getContainer()->get(FormFactoryInterface::class); + + return $formFactory->create(FinancialDataType::class, $formData, [ + 'csrf_protection' => false, + ]); + } +} diff --git a/tests/Form/PersonalDataConfirmationTypeTest.php b/tests/Form/PersonalDataConfirmationTypeTest.php new file mode 100644 index 0000000..887a47d --- /dev/null +++ b/tests/Form/PersonalDataConfirmationTypeTest.php @@ -0,0 +1,84 @@ +setCreatedAt(new \DateTimeImmutable('2023-10-11 16:29:04')); + $user = (new User()) + ->setRoles(['ROLE_TEAMER']) + ->setTeamer($teamer) + ; + + $check = new PersonalDataVerificationRequiredCheck(new RecurringDeadlines(['04-01', '10-01'])); + $this->assertFalse($check->isSatisfied($user)); + + $form = $this->createForm(); + $form->submit(['confirmed' => '1']); + + $this->assertTrue($form->isValid()); + + // what PersonalDataController does on a valid submit + $teamer->setDataVerifiedAt(new \DateTimeImmutable('2026-08-10 12:00:00')); + + $this->assertTrue($check->isSatisfied($user)); + $this->assertNull($teamer->getPhoto()); + $this->assertNull($teamer->getLanguage()); + $this->assertNull($teamer->getHealthInsuranceCompany()); + } + + public function testAnUntickedBoxIsRejected(): void + { + $form = $this->createForm(); + $form->submit(['confirmed' => null]); + + $this->assertFalse($form->isValid()); + $this->assertCount(1, $form->get('confirmed')->getErrors()); + } + + private function createForm(): FormInterface + { + self::bootKernel(); + + /** @var FormFactoryInterface $formFactory */ + $formFactory = self::getContainer()->get(FormFactoryInterface::class); + + return $formFactory->create(PersonalDataConfirmationType::class, new PersonalDataConfirmationDto(), [ + 'csrf_protection' => false, + ]); + } +} diff --git a/tests/Security/RequiredCheck/FinancialDataRequiredCheckTest.php b/tests/Security/RequiredCheck/FinancialDataRequiredCheckTest.php new file mode 100644 index 0000000..2606159 --- /dev/null +++ b/tests/Security/RequiredCheck/FinancialDataRequiredCheckTest.php @@ -0,0 +1,136 @@ +check = new FinancialDataRequiredCheck(); + } + + /** + * @dataProvider mixedRoleProvider + */ + public function testDoesNotApplyToExcludedMixedRoleUsersWithTeamerRole(string $excludedRole): void + { + $user = $this->createTeamerUser(); + $user->setRoles([$excludedRole, 'ROLE_TEAMER']); + + $this->assertFalse($this->check->appliesTo($user)); + } + + public function mixedRoleProvider(): array + { + return [ + ['ROLE_ADMIN'], + ['ROLE_MANAGER'], + ['ROLE_HOUSE_MANAGER'], + ]; + } + + public function testRedirectsToItsOwnCheckPage(): void + { + // must not be the full profile form, which would also demand photo, + // language and health insurance and could lock the teamer out + $this->assertSame('app_teamer_check_financial_data', $this->check->getRouteName()); + } + + public function testIsNotSatisfiedWithoutTeamer(): void + { + $user = (new User())->setRoles(['ROLE_TEAMER']); + + $this->assertFalse($this->check->isSatisfied($user)); + } + + public function testIsNotSatisfiedWhenNothingIsSet(): void + { + $user = $this->createTeamerUser(); + + $this->assertFalse($this->check->isSatisfied($user)); + } + + public function testIsNotSatisfiedWhenTaxIdIsMissing(): void + { + $user = $this->createTeamerUser(); + $user->getTeamer()?->setBankAccount($this->createBankAccount()); + + $this->assertFalse($this->check->isSatisfied($user)); + } + + public function testIsNotSatisfiedWhenBankAccountIsMissing(): void + { + $user = $this->createTeamerUser(); + $user->getTeamer()?->setTaxId('12345678901'); + + $this->assertFalse($this->check->isSatisfied($user)); + } + + /** + * @dataProvider missingBankAccountFieldProvider + */ + public function testIsNotSatisfiedWhenBankAccountFieldIsMissing(string $setter): void + { + $bankAccount = $this->createBankAccount(); + $bankAccount->{$setter}(null); + + $user = $this->createTeamerUser(); + $user->getTeamer() + ?->setTaxId('12345678901') + ->setBankAccount($bankAccount) + ; + + $this->assertFalse($this->check->isSatisfied($user)); + } + + public function missingBankAccountFieldProvider(): array + { + return [ + ['setIban'], + ['setBic'], + ['setBank'], + ['setHolder'], + ]; + } + + public function testIsSatisfiedWhenTaxIdAndBankAccountAreComplete(): void + { + $user = $this->createTeamerUser(); + $user->getTeamer() + ?->setTaxId('12345678901') + ->setBankAccount($this->createBankAccount()) + ; + + $this->assertTrue($this->check->isSatisfied($user)); + } + + private function createBankAccount(): BankAccount + { + return (new BankAccount()) + ->setIban('DE02120300000000202051') + ->setBic('BYLADEM1001') + ->setBank('Deutsche Kreditbank') + ->setHolder('Erika Mustermann') + ; + } + + private function createTeamerUser(): User + { + $teamer = new Teamer(); + + return (new User()) + ->setRoles(['ROLE_TEAMER']) + ->setTeamer($teamer) + ; + } +} diff --git a/tests/Security/RequiredCheck/PersonalDataVerificationRequiredCheckTest.php b/tests/Security/RequiredCheck/PersonalDataVerificationRequiredCheckTest.php index d35d646..9915dba 100644 --- a/tests/Security/RequiredCheck/PersonalDataVerificationRequiredCheckTest.php +++ b/tests/Security/RequiredCheck/PersonalDataVerificationRequiredCheckTest.php @@ -7,6 +7,7 @@ namespace App\Tests\Security\RequiredCheck; use App\Entity\Teamer; use App\Entity\User; use App\RequiredTeamerCheck\PersonalDataVerificationRequiredCheck; +use App\RequiredTeamerCheck\RecurringDeadlines; use Carbon\CarbonImmutable; use PHPUnit\Framework\TestCase; @@ -24,10 +25,9 @@ class PersonalDataVerificationRequiredCheckTest extends TestCase public function testAppliesToTeamerUsers(): void { - $check = new PersonalDataVerificationRequiredCheck(); $user = (new User())->setRoles(['ROLE_TEAMER']); - $this->assertTrue($check->appliesTo($user)); + $this->assertTrue($this->createCheck()->appliesTo($user)); } /** @@ -35,10 +35,9 @@ class PersonalDataVerificationRequiredCheckTest extends TestCase */ public function testDoesNotApplyToExcludedMixedRoleUsersWithTeamerRole(string $excludedRole): void { - $check = new PersonalDataVerificationRequiredCheck(); $user = (new User())->setRoles([$excludedRole, 'ROLE_TEAMER']); - $this->assertFalse($check->appliesTo($user)); + $this->assertFalse($this->createCheck()->appliesTo($user)); } public function mixedRoleProvider(): array @@ -50,115 +49,63 @@ class PersonalDataVerificationRequiredCheckTest extends TestCase ]; } + public function testRedirectsToItsOwnConfirmationPage(): void + { + // not the profile form, which demands the whole 'profile' group and can + // therefore be impossible to submit + $this->assertSame('app_teamer_check_personal_data', $this->createCheck()->getRouteName()); + } + public function testIsNotSatisfiedWhenTeamerIsMissing(): void { - $check = new PersonalDataVerificationRequiredCheck(); $user = (new User())->setRoles(['ROLE_TEAMER']); - $this->assertFalse($check->isSatisfied($user)); + $this->assertFalse($this->createCheck()->isSatisfied($user)); } - public function testIsSatisfiedBeforeFirstDeadlineEvenWithoutVerification(): void + public function testToleratesAnUnpersistedTeamerWithoutACreationDate(): void { - CarbonImmutable::setTestNow('2026-01-10 12:00:00'); - $check = new PersonalDataVerificationRequiredCheck(['01-15', '07-15']); + CarbonImmutable::setTestNow('2026-01-20 12:00:00'); - $teamer = (new Teamer())->setCreatedAt(new \DateTimeImmutable('2025-11-05 12:00:00')); - $user = (new User()) - ->setRoles(['ROLE_TEAMER']) - ->setTeamer($teamer) - ; - - $this->assertTrue($check->isSatisfied($user)); + // Teamer::getCreatedAt() is declared non-nullable but throws until flushed + $this->assertFalse($this->createCheck()->isSatisfied($this->createUser(new Teamer()))); } - public function testIsNotSatisfiedAfterFirstDeadlineWhenNeverVerified(): void + public function testDelegatesTheDueDecisionToItsSchedule(): void { CarbonImmutable::setTestNow('2026-01-20 12:00:00'); - $check = new PersonalDataVerificationRequiredCheck(['01-15', '07-15']); $teamer = (new Teamer())->setCreatedAt(new \DateTimeImmutable('2025-11-05 12:00:00')); - $user = (new User()) - ->setRoles(['ROLE_TEAMER']) - ->setTeamer($teamer) - ; + $user = $this->createUser($teamer); - $this->assertFalse($check->isSatisfied($user)); + $this->assertFalse($this->createCheck()->isSatisfied($user)); + + $teamer->setDataVerifiedAt(new \DateTimeImmutable('2026-01-16 10:00:00')); + + $this->assertTrue($this->createCheck()->isSatisfied($user)); } - public function testIsSatisfiedWhenVerifiedAfterCurrentDeadline(): void + public function testAnEmptyScheduleNeverForcesVerification(): void { - CarbonImmutable::setTestNow('2026-05-20 12:00:00'); - $check = new PersonalDataVerificationRequiredCheck(['01-15', '07-15']); + CarbonImmutable::setTestNow('2026-01-20 12:00:00'); - $teamer = (new Teamer()) - ->setCreatedAt(new \DateTimeImmutable('2025-11-05 12:00:00')) - ->setDataVerifiedAt(new \DateTimeImmutable('2026-01-16 10:00:00')) - ; - $user = (new User()) - ->setRoles(['ROLE_TEAMER']) - ->setTeamer($teamer) - ; + $teamer = (new Teamer())->setCreatedAt(new \DateTimeImmutable('2025-11-05 12:00:00')); - $this->assertTrue($check->isSatisfied($user)); + $check = new PersonalDataVerificationRequiredCheck(new RecurringDeadlines([])); + + $this->assertTrue($check->isSatisfied($this->createUser($teamer))); } - public function testIsNotSatisfiedWhenVerificationIsOutdated(): void + private function createCheck(): PersonalDataVerificationRequiredCheck { - CarbonImmutable::setTestNow('2026-07-20 12:00:00'); - $check = new PersonalDataVerificationRequiredCheck(['01-15', '07-15']); - - $teamer = (new Teamer()) - ->setCreatedAt(new \DateTimeImmutable('2025-11-05 12:00:00')) - ->setDataVerifiedAt(new \DateTimeImmutable('2026-02-01 10:00:00')) - ; - $user = (new User()) - ->setRoles(['ROLE_TEAMER']) - ->setTeamer($teamer) - ; - - $this->assertFalse($check->isSatisfied($user)); + return new PersonalDataVerificationRequiredCheck(new RecurringDeadlines(['01-15', '07-15'])); } - public function testIsSatisfiedAfterFirstDeadlineWhenRegisteredAfterThatDeadline(): void + private function createUser(Teamer $teamer): User { - CarbonImmutable::setTestNow('2026-05-20 12:00:00'); - $check = new PersonalDataVerificationRequiredCheck(['01-15', '07-15']); - - $teamer = (new Teamer())->setCreatedAt(new \DateTimeImmutable('2026-03-01 10:00:00')); - $user = (new User()) + return (new User()) ->setRoles(['ROLE_TEAMER']) ->setTeamer($teamer) ; - - $this->assertTrue($check->isSatisfied($user)); - } - - public function testIsSatisfiedAfterSecondDeadlineWhenRegisteredAfterThatDeadline(): void - { - CarbonImmutable::setTestNow('2026-11-20 12:00:00'); - $check = new PersonalDataVerificationRequiredCheck(['01-15', '07-15']); - - $teamer = (new Teamer())->setCreatedAt(new \DateTimeImmutable('2026-10-01 10:00:00')); - $user = (new User()) - ->setRoles(['ROLE_TEAMER']) - ->setTeamer($teamer) - ; - - $this->assertTrue($check->isSatisfied($user)); - } - - public function testIsNotSatisfiedAfterSecondDeadlineWhenRegisteredBeforeCurrentYearDeadlines(): void - { - CarbonImmutable::setTestNow('2026-11-20 12:00:00'); - $check = new PersonalDataVerificationRequiredCheck(['01-15', '07-15']); - - $teamer = (new Teamer())->setCreatedAt(new \DateTimeImmutable('2025-10-01 10:00:00')); - $user = (new User()) - ->setRoles(['ROLE_TEAMER']) - ->setTeamer($teamer) - ; - - $this->assertFalse($check->isSatisfied($user)); } } diff --git a/tests/Security/RequiredCheck/RecurringDeadlinesTest.php b/tests/Security/RequiredCheck/RecurringDeadlinesTest.php new file mode 100644 index 0000000..3fcc06d --- /dev/null +++ b/tests/Security/RequiredCheck/RecurringDeadlinesTest.php @@ -0,0 +1,125 @@ +assertFalse($deadlines->isDue(null, new \DateTimeImmutable('2020-01-01'), CarbonImmutable::parse('2026-08-10'))); + $this->assertNull($deadlines->getCurrentDeadline(CarbonImmutable::parse('2026-08-10'))); + } + + public function testIsNotDueBeforeTheFirstDeadlineEvenWhenNeverConfirmed(): void + { + $deadlines = new RecurringDeadlines(['01-15', '07-15']); + + $this->assertFalse($deadlines->isDue( + null, + new \DateTimeImmutable('2025-11-05 12:00:00'), + CarbonImmutable::parse('2026-01-10 12:00:00'), + )); + } + + public function testIsDueAfterTheFirstDeadlineWhenNeverConfirmed(): void + { + $deadlines = new RecurringDeadlines(['01-15', '07-15']); + + $this->assertTrue($deadlines->isDue( + null, + new \DateTimeImmutable('2025-11-05 12:00:00'), + CarbonImmutable::parse('2026-01-20 12:00:00'), + )); + } + + public function testIsNotDueWhenConfirmedAfterTheCurrentDeadline(): void + { + $deadlines = new RecurringDeadlines(['01-15', '07-15']); + + $this->assertFalse($deadlines->isDue( + new \DateTimeImmutable('2026-01-16 10:00:00'), + new \DateTimeImmutable('2025-11-05 12:00:00'), + CarbonImmutable::parse('2026-05-20 12:00:00'), + )); + } + + public function testIsDueWhenTheConfirmationPredatesTheCurrentDeadline(): void + { + $deadlines = new RecurringDeadlines(['01-15', '07-15']); + + $this->assertTrue($deadlines->isDue( + new \DateTimeImmutable('2026-02-01 10:00:00'), + new \DateTimeImmutable('2025-11-05 12:00:00'), + CarbonImmutable::parse('2026-07-20 12:00:00'), + )); + } + + public function testIsNotDueWhenRegisteredAfterTheFirstDeadline(): void + { + $deadlines = new RecurringDeadlines(['01-15', '07-15']); + + $this->assertFalse($deadlines->isDue( + null, + new \DateTimeImmutable('2026-03-01 10:00:00'), + CarbonImmutable::parse('2026-05-20 12:00:00'), + )); + } + + public function testIsNotDueWhenRegisteredAfterTheSecondDeadline(): void + { + $deadlines = new RecurringDeadlines(['01-15', '07-15']); + + $this->assertFalse($deadlines->isDue( + null, + new \DateTimeImmutable('2026-10-01 10:00:00'), + CarbonImmutable::parse('2026-11-20 12:00:00'), + )); + } + + public function testIsDueWhenRegisteredBeforeAllCurrentYearDeadlines(): void + { + $deadlines = new RecurringDeadlines(['01-15', '07-15']); + + $this->assertTrue($deadlines->isDue( + null, + new \DateTimeImmutable('2025-10-01 10:00:00'), + CarbonImmutable::parse('2026-11-20 12:00:00'), + )); + } + + public function testAMissingRegistrationDateDoesNotExempt(): void + { + $deadlines = new RecurringDeadlines(['01-15', '07-15']); + + $this->assertTrue($deadlines->isDue(null, null, CarbonImmutable::parse('2026-01-20 12:00:00'))); + } + + public function testGetCurrentDeadlineReturnsTheMostRecentlyPassedOne(): void + { + $deadlines = new RecurringDeadlines(['01-15', '07-15']); + + $this->assertSame( + '2026-07-15', + $deadlines->getCurrentDeadline(CarbonImmutable::parse('2026-08-10'))?->format('Y-m-d'), + ); + $this->assertNull($deadlines->getCurrentDeadline(CarbonImmutable::parse('2026-01-10'))); + } +}