diff --git a/src/State/EmployeeRttPaymentProcessor.php b/src/State/EmployeeRttPaymentProcessor.php index 4b661ab..48d5e75 100644 --- a/src/State/EmployeeRttPaymentProcessor.php +++ b/src/State/EmployeeRttPaymentProcessor.php @@ -12,8 +12,10 @@ use App\Entity\EmployeeRttPayment; use App\Repository\EmployeeRepository; use App\Repository\EmployeeRttPaymentRepository; use App\Service\AuditLogger; +use App\Service\Contracts\EmployeeContractPhaseResolver; use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; +use Psr\Clock\ClockInterface; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; @@ -24,6 +26,8 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface private EmployeeRttPaymentRepository $rttPaymentRepository, private EntityManagerInterface $entityManager, private AuditLogger $auditLogger, + private EmployeeContractPhaseResolver $phaseResolver, + private ClockInterface $clock, ) {} public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): EmployeeRttPaymentInput @@ -48,6 +52,8 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface $year = $data->year ?? $this->resolveCurrentExerciseYear(); + $this->assertYearAllowedForPayment($employee, $year); + $payment = $this->rttPaymentRepository->findOneByEmployeeYearMonth($employee, $year, $data->month); if (null === $payment) { @@ -83,10 +89,44 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface private function resolveCurrentExerciseYear(): int { - $today = new DateTimeImmutable('today'); - $year = (int) $today->format('Y'); - $month = (int) $today->format('n'); + return $this->resolveExerciseYearForDate($this->clock->now()); + } + + /** + * Map a date to the RTT exercise year it belongs to (Juin N-1 → Mai N convention). + */ + private function resolveExerciseYearForDate(DateTimeImmutable $date): int + { + $year = (int) $date->format('Y'); + $month = (int) $date->format('n'); return $month >= 6 ? $year + 1 : $year; } + + /** + * Allow payment when the requested exercise is either the current one + * or the last exercise of a closed contract phase (the one containing + * the phase end date). Reject any other exercise (past or future). + */ + private function assertYearAllowedForPayment(Employee $employee, int $year): void + { + $currentExerciseYear = $this->resolveCurrentExerciseYear(); + if ($year === $currentExerciseYear) { + return; + } + + $phases = $this->phaseResolver->resolvePhases($employee); + foreach ($phases as $phase) { + if ($phase->isCurrent || null === $phase->endDate) { + continue; + } + if ($year === $this->resolveExerciseYearForDate($phase->endDate)) { + return; + } + } + + throw new UnprocessableEntityHttpException( + 'RTT payment is only allowed on the current exercise or the last exercise of a closed contract phase.' + ); + } } diff --git a/tests/State/EmployeeRttPaymentProcessorTest.php b/tests/State/EmployeeRttPaymentProcessorTest.php new file mode 100644 index 0000000..87e877f --- /dev/null +++ b/tests/State/EmployeeRttPaymentProcessorTest.php @@ -0,0 +1,167 @@ +buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); + $processor = $this->buildProcessorWithClock(new DateTimeImmutable('2026-05-19')); + + $this->invokePrivate($processor, 'assertYearAllowedForPayment', $employee, 2026); + + // No exception → guard accepts current exercise. + self::assertTrue(true); + } + + public function testPaymentAllowedOnLastExerciseOfClosedPhase(): void + { + // Phase 39h closed 2026-04-30, FORFAIT from 2026-05-01. + // Exercise 2026 (Juin 2025 → Mai 2026) contains the H39 phase end date. + // Payment must be allowed on exercise 2026 even when current exercise is 2027. + $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); + $processor = $this->buildProcessorWithClock(new DateTimeImmutable('2027-01-15')); + + $this->invokePrivate($processor, 'assertYearAllowedForPayment', $employee, 2026); + + self::assertTrue(true); + } + + public function testPaymentRejectedOnEarlierExerciseOfClosedPhase(): void + { + $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); + $processor = $this->buildProcessorWithClock(new DateTimeImmutable('2027-01-15')); + + $this->expectException(UnprocessableEntityHttpException::class); + $this->invokePrivate($processor, 'assertYearAllowedForPayment', $employee, 2024); + } + + public function testPaymentRejectedOnFutureExercise(): void + { + $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); + $processor = $this->buildProcessorWithClock(new DateTimeImmutable('2026-05-19')); + + $this->expectException(UnprocessableEntityHttpException::class); + $this->invokePrivate($processor, 'assertYearAllowedForPayment', $employee, 2030); + } + + // ----------------------------------------------------------------------- + // Test harness helpers. + // ----------------------------------------------------------------------- + + /** + * Build a two-period employee transitioning from H39 to FORFAIT. + */ + private function buildEmployeeWithTransition(string $h39Start, string $h39End, string $forfaitStart): Employee + { + $employee = new Employee(); + $this->setEntityId($employee, 1); + + $h39Contract = new Contract(); + $h39Contract->setName('39H'); + $h39Contract->setTrackingMode(TrackingMode::TIME->value); + $h39Contract->setWeeklyHours(39); + + $forfaitContract = new Contract(); + $forfaitContract->setName('Forfait'); + $forfaitContract->setTrackingMode(TrackingMode::PRESENCE->value); + $forfaitContract->setWeeklyHours(null); + + $h39Period = new EmployeeContractPeriod(); + $this->setEntityId($h39Period, 1); + $h39Period->setEmployee($employee); + $h39Period->setContract($h39Contract); + $h39Period->setStartDate(new DateTimeImmutable($h39Start)); + $h39Period->setEndDate(new DateTimeImmutable($h39End)); + $h39Period->setContractNature(ContractNature::CDI); + $h39Period->setIsDriver(false); + + $forfaitPeriod = new EmployeeContractPeriod(); + $this->setEntityId($forfaitPeriod, 2); + $forfaitPeriod->setEmployee($employee); + $forfaitPeriod->setContract($forfaitContract); + $forfaitPeriod->setStartDate(new DateTimeImmutable($forfaitStart)); + $forfaitPeriod->setEndDate(null); + $forfaitPeriod->setContractNature(ContractNature::CDI); + $forfaitPeriod->setIsDriver(false); + + $employee->getContractPeriods()->add($h39Period); + $employee->getContractPeriods()->add($forfaitPeriod); + + return $employee; + } + + /** + * Build an uninitialized processor with a fixed clock. The repositories are + * declared on final classes that PHPUnit cannot double, so we bypass full + * instantiation via newInstanceWithoutConstructor and only seed the + * properties the tested private guard reads: phaseResolver + clock. + */ + private function buildProcessorWithClock(DateTimeImmutable $today): EmployeeRttPaymentProcessor + { + $reflection = new ReflectionClass(EmployeeRttPaymentProcessor::class); + $processor = $reflection->newInstanceWithoutConstructor(); + + $clock = new readonly class($today) implements ClockInterface { + public function __construct(private DateTimeImmutable $now) {} + + public function now(): DateTimeImmutable + { + return $this->now; + } + }; + + $this->setReadonlyProperty($processor, 'phaseResolver', new EmployeeContractPhaseResolver()); + $this->setReadonlyProperty($processor, 'clock', $clock); + + return $processor; + } + + private function invokePrivate(object $obj, string $method, mixed ...$args): mixed + { + $reflection = new ReflectionClass($obj::class); + $m = $reflection->getMethod($method); + + return $m->invoke($obj, ...$args); + } + + private function setReadonlyProperty(object $obj, string $property, mixed $value): void + { + $reflection = new ReflectionProperty($obj::class, $property); + $reflection->setValue($obj, $value); + } + + private function setEntityId(object $entity, int $id): void + { + $reflection = new ReflectionProperty($entity::class, 'id'); + $reflection->setValue($entity, $id); + } +}