feat: financial data check for teamers, improved enforced checks logic

addresses #869at5rtp
This commit is contained in:
Björn Fromme
2026-08-10 18:06:25 +02:00
parent fd5d478a5c
commit 388ecf9603
22 changed files with 1196 additions and 195 deletions
-2
View File
@@ -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
+13 -1
View File
@@ -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
@@ -0,0 +1,62 @@
<?php
namespace App\Controller\Teamer\Check;
use App\Entity\Teamer;
use App\Entity\User;
use App\Form\FinancialDataType;
use App\Model\FinancialDataDto;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
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;
class FinancialDataController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/teamer/check/financial-data', name: 'app_teamer_check_financial_data')]
#[IsGranted('ROLE_TEAMER')]
public function index(Request $request): Response
{
/** @var User $user */
$user = $this->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,
]);
}
}
@@ -0,0 +1,65 @@
<?php
namespace App\Controller\Teamer\Check;
use App\Entity\Teamer;
use App\Entity\User;
use App\Form\PersonalDataConfirmationType;
use App\Model\PersonalDataConfirmationDto;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
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;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class PersonalDataController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ValidatorInterface $validator,
private readonly LoggerInterface $logger,
) {
}
#[Route('/teamer/check/personal-data', name: 'app_teamer_check_personal_data')]
#[IsGranted('ROLE_TEAMER')]
public function index(Request $request): Response
{
/** @var User $user */
$user = $this->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']),
]);
}
}
@@ -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) {
// 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,
]);
}
@@ -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)) {
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Form;
use App\Model\FinancialDataDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class FinancialDataType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->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,
]);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Form;
use App\Model\PersonalDataConfirmationDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class PersonalDataConfirmationType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->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,
]);
}
}
-17
View File
@@ -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')
;
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
namespace App\Model;
use App\Entity\Embeddable\BankAccount;
use App\Entity\Teamer;
use Symfony\Component\Validator\Constraints as Assert;
class FinancialDataDto
{
#[Assert\NotBlank(message: 'Bitte gib deine SteuerID an')]
private ?string $taxId = null;
#[Assert\NotBlank(message: 'Bitte gib die IBAN an')]
#[Assert\Iban(message: 'Bitte gib eine gültige IBAN an')]
private ?string $iban = null;
#[Assert\NotBlank(message: 'Bitte gib den BIC an')]
private ?string $bic = null;
#[Assert\NotBlank(message: 'Bitte gib den Namen deiner Bank an')]
private ?string $bank = null;
#[Assert\NotBlank(message: 'Bitte gib den Kontoinhaber an')]
private ?string $holder = null;
public static function fromTeamer(Teamer $teamer): self
{
$instance = (new self())->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;
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Model;
use Symfony\Component\Validator\Constraints as Assert;
class PersonalDataConfirmationDto
{
#[Assert\IsTrue(message: 'Deine Bestätigung ist erforderlich')]
private ?bool $confirmed = false;
public function getConfirmed(): ?bool
{
return $this->confirmed;
}
public function setConfirmed(?bool $confirmed): static
{
$this->confirmed = $confirmed;
return $this;
}
}
@@ -0,0 +1,55 @@
<?php
namespace App\RequiredTeamerCheck;
use App\Entity\User;
class FinancialDataRequiredCheck extends AbstractRequiredTeamerCheck
{
public function getCode(): string
{
return 'financial_data';
}
public function isSatisfied(User $user): bool
{
$teamer = $user->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';
}
}
@@ -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();
}
return $deadlines;
}
private function getCurrentDeadline(CarbonImmutable $now): ?CarbonImmutable
{
$deadlines = $this->getDeadlinesForYear($now);
rsort($deadlines);
foreach ($deadlines as $deadline) {
if ($deadline <= $now) {
return $deadline;
}
}
try {
return $teamer->getCreatedAt();
} catch (\TypeError) {
return null;
}
}
}
@@ -0,0 +1,104 @@
<?php
namespace App\RequiredTeamerCheck;
use Carbon\CarbonImmutable;
/**
* A set of yearly recurring dates after which something must be confirmed again.
*
* Belongs to the individual check that composes it, never to the application as a
* whole: a deadline expresses "this particular confirmation has expired", not
* "portal access has expired".
*/
final class RecurringDeadlines
{
/**
* @param string[] $deadlines dates as MM-DD, e.g. ['04-01', '10-01']; an empty
* list is never due
*/
public function __construct(private readonly array $deadlines)
{
}
/**
* Whether a confirmation is outstanding: never confirmed, or last confirmed
* before the most recent deadline that has passed.
*/
public function isDue(?\DateTimeInterface $confirmedAt, ?\DateTimeInterface $registeredAt, CarbonImmutable $now): bool
{
$deadline = $this->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;
}
}
@@ -0,0 +1,40 @@
{% extends 'teamer/layout.html.twig' %}
{% block title %}Honorardaten{% endblock %}
{% block content %}
{{ form_start(form) }}
<div class="max-w-2xl">
<h1 class="text-2xl font-bold pb-2">
Bankverbindung und Steuer-ID
</h1>
<p class="pb-6">
Damit wir dein Honorar auszahlen können, benötigen wir einmalig deine Steuer-ID und deine
Bankverbindung. Bitte ergänze die folgenden Angaben:
</p>
{% 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 %}
<div class="flex flex-col space-y-4 pb-6">
{{ form_row(form.taxId) }}
{{ form_row(form.iban) }}
{{ form_row(form.bic) }}
{{ form_row(form.bank) }}
{{ form_row(form.holder) }}
</div>
<button type="submit" class="btn">
Speichern
</button>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
@@ -0,0 +1,84 @@
{% extends 'teamer/layout.html.twig' %}
{% block title %}Datenbestätigung{% endblock %}
{% block content %}
{{ form_start(form) }}
<div class="max-w-2xl">
<h1 class="text-2xl font-bold pb-2">
Datenbestätigung
</h1>
<p class="pb-6">
Bitte prüfe, ob deine hinterlegten Daten noch aktuell sind, und bestätige sie.
</p>
{% 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 %}
<div class="border border-gray-300 rounded-md p-4 mb-6">
<div class="flex flex-col space-y-3">
<div>
<div class="font-bold">Name</div>
<div>{{ teamer.fullName }}</div>
</div>
<div>
<div class="font-bold">Geburtsdatum</div>
<div>{{ teamer.dateOfBirth ? teamer.dateOfBirth|date('d.m.Y') : '-' }}</div>
</div>
<div>
<div class="font-bold">Anschrift</div>
<div>
{{ teamer.address.street|default('-') }}<br>
{{ teamer.address.postCode|default('') }} {{ teamer.address.city|default('') }}<br>
{{ teamer.address.country|default('') }}
</div>
</div>
<div>
<div class="font-bold">Kontakt</div>
<div>
{{ teamer.communication.email|default('-') }}<br>
{{ teamer.communication.mobile|default('-') }}
</div>
</div>
</div>
</div>
{% if errors|length %}
{% embed '_partials/_alert.html.twig' with { 'type': 'warning' } %}
{% block message %}
Einige Angaben in deinem Profil fehlen noch:
<ul class="list-disc list-inside pt-2">
{% for error in errors %}
<li>{{ error.message }}</li>
{% endfor %}
</ul>
<a href="{{ path('app_teamer_profile_index') }}" class="underline font-bold inline-block pt-2">
Im Profil ergänzen
</a>
{% endblock %}
{% endembed %}
{% endif %}
<p class="pb-4">
Stimmt etwas nicht?
<a href="{{ path('app_teamer_profile_index') }}" class="underline font-bold">Daten im Profil ändern</a>
</p>
<div class="pb-6">
{{ form_row(form.confirmed) }}
</div>
<button type="submit" class="btn">
Bestätigen
</button>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
+1 -10
View File
@@ -53,9 +53,6 @@
<h1 class="text-2xl font-bold pb-8">
Mein Profil
</h1>
{% 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 %}
<div class="border border-red-500 rounded-md p-4 text-red-500 mb-8">
{% include '_partials/_form_errors.html.twig' with { 'form': form } %}
@@ -192,14 +189,8 @@
} %}
</div>
{% if verification_mode %}
<div class="pb-4">
{{ form_row(form.confirmDataVerification) }}
</div>
{% endif %}
<button type="submit" class="btn" {{ stimulus_target('form-upload-guard', 'submit') }}>
{{ verification_mode ? 'Daten bestätigen' : 'Aktualisieren' }}
Aktualisieren
</button>
</div>
+166
View File
@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form;
use App\Entity\Embeddable\BankAccount;
use App\Entity\Teamer;
use App\Entity\User;
use App\Form\FinancialDataType;
use App\Model\FinancialDataDto;
use App\RequiredTeamerCheck\FinancialDataRequiredCheck;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
class FinancialDataTypeTest extends KernelTestCase
{
/**
* The whole point of the dedicated check page: a teamer who is missing photo,
* language, health insurance and address fails the entity's 'profile' group,
* but must still be able to save the five fields that open the gate. The DTO
* carries its constraints in the default group, so nothing from the entity
* bleeds in.
*/
public function testSubmitSucceedsForATeamerWhoFailsTheProfileGroup(): void
{
$teamer = new Teamer();
$formData = FinancialDataDto::fromTeamer($teamer);
$form = $this->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,
]);
}
}
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form;
use App\Entity\Teamer;
use App\Entity\User;
use App\Form\PersonalDataConfirmationType;
use App\Model\PersonalDataConfirmationDto;
use App\RequiredTeamerCheck\PersonalDataVerificationRequiredCheck;
use App\RequiredTeamerCheck\RecurringDeadlines;
use Carbon\CarbonImmutable;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
class PersonalDataConfirmationTypeTest extends KernelTestCase
{
protected function setUp(): void
{
CarbonImmutable::setTestNow();
}
protected function tearDown(): void
{
CarbonImmutable::setTestNow();
}
/**
* The invariant that keeps registry order out of the critical path: this check's
* own page must clear this check, no matter what else the teamer is missing.
* Previously verification lived on the profile form, whose 'profile' group also
* demands photo, language, health insurance, address and DOB — so a teamer in
* this state could never get past it.
*/
public function testConfirmingClearsTheCheckForAnOtherwiseIncompleteTeamer(): void
{
CarbonImmutable::setTestNow('2026-08-10 12:00:00');
$teamer = (new Teamer())->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,
]);
}
}
@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace App\Tests\Security\RequiredCheck;
use App\Entity\Embeddable\BankAccount;
use App\Entity\Teamer;
use App\Entity\User;
use App\RequiredTeamerCheck\FinancialDataRequiredCheck;
use PHPUnit\Framework\TestCase;
class FinancialDataRequiredCheckTest extends TestCase
{
private FinancialDataRequiredCheck $check;
protected function setUp(): void
{
$this->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)
;
}
}
@@ -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));
}
}
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Tests\Security\RequiredCheck;
use App\RequiredTeamerCheck\RecurringDeadlines;
use Carbon\CarbonImmutable;
use PHPUnit\Framework\TestCase;
class RecurringDeadlinesTest extends TestCase
{
protected function setUp(): void
{
CarbonImmutable::setTestNow();
}
protected function tearDown(): void
{
CarbonImmutable::setTestNow();
}
public function testAnEmptyScheduleIsNeverDue(): void
{
$deadlines = new RecurringDeadlines([]);
$this->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')));
}
}