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
@@ -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();
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;
}
}
@@ -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;
}
}