diff --git a/src/Module/Absence/Infrastructure/Command/AccrueLeaveCommand.php b/src/Module/Absence/Infrastructure/Command/AccrueLeaveCommand.php index 7257dc8..81c67ca 100644 --- a/src/Module/Absence/Infrastructure/Command/AccrueLeaveCommand.php +++ b/src/Module/Absence/Infrastructure/Command/AccrueLeaveCommand.php @@ -111,9 +111,18 @@ class AccrueLeaveCommand extends Command $previousBalance = null !== $previousPeriod ? $this->balanceRepository->findOneForPeriod($user, AbsenceType::PaidLeave, $previousPeriod) : null; - $balance->setAcquired( - null !== $previousBalance ? $previousBalance->getAcquiring() : $profile->getInitialLeaveBalance(), - ); + + if (null !== $previousBalance) { + // Only the days *not yet taken* carry over. Leave is charged + // oldest-first: it first consumes the previous "acquired" + // (N-2) bucket — which expires at roll-over anyway — so only + // days taken beyond that bucket eat into the carry-over. + $carryOver = $previousBalance->getAcquiring() + - max(0.0, $previousBalance->getTaken() - $previousBalance->getAcquired()); + $balance->setAcquired(max(0.0, $carryOver)); + } else { + $balance->setAcquired($profile->getInitialLeaveBalance()); + } } if ($monthKey === $balance->getLastAccruedMonth()) { diff --git a/tests/Functional/Command/AccrueLeaveCommandTest.php b/tests/Functional/Command/AccrueLeaveCommandTest.php new file mode 100644 index 0000000..28fac84 --- /dev/null +++ b/tests/Functional/Command/AccrueLeaveCommandTest.php @@ -0,0 +1,120 @@ +em = self::getContainer()->get(EntityManagerInterface::class); + $this->balances = self::getContainer()->get(DoctrineAbsenceBalanceRepository::class); + } + + /** + * Tristan's real case: 9.75 accruing, 1 day taken, nothing previously + * acquired → the day taken eats into the carry-over, so 8.75 rolls over + * (not 9.75). + */ + public function testCarryOverDeductsTakenDays(): void + { + $user = $this->createEmployee(); + $this->seedPreviousBalance($user, acquired: 0.0, acquiring: 9.75, taken: 1.0); + + $this->runForJune2026(); + + $rolled = $this->balances->findOneForPeriod($user, AbsenceType::PaidLeave, '2026-2027'); + self::assertNotNull($rolled); + self::assertEqualsWithDelta(8.75, $rolled->getAcquired(), 0.0001); + } + + /** + * Leave is charged oldest-first: the 3 days taken come out of the expiring + * N-2 "acquired" bucket (5), so the full 10 accruing days carry over intact. + */ + public function testCarryOverChargesOldestBucketFirst(): void + { + $user = $this->createEmployee(); + $this->seedPreviousBalance($user, acquired: 5.0, acquiring: 10.0, taken: 3.0); + + $this->runForJune2026(); + + $rolled = $this->balances->findOneForPeriod($user, AbsenceType::PaidLeave, '2026-2027'); + self::assertNotNull($rolled); + self::assertEqualsWithDelta(10.0, $rolled->getAcquired(), 0.0001); + } + + /** No day taken → the whole accruing bucket carries over. */ + public function testFullCarryOverWhenNothingTaken(): void + { + $user = $this->createEmployee(); + $this->seedPreviousBalance($user, acquired: 0.0, acquiring: 10.0, taken: 0.0); + + $this->runForJune2026(); + + $rolled = $this->balances->findOneForPeriod($user, AbsenceType::PaidLeave, '2026-2027'); + self::assertNotNull($rolled); + self::assertEqualsWithDelta(10.0, $rolled->getAcquired(), 0.0001); + } + + private function createEmployee(): User + { + $user = new User(); + $user->setUsername('accrue-test-'.uniqid()); + $user->setPassword('x'); + $user->setRoles(['ROLE_USER']); + $user->setIsEmployee(true); + $user->setHireDate(new DateTimeImmutable('2024-01-01')); + $user->setReferencePeriodStart('06-01'); + $user->setAnnualLeaveDays(25.0); + $user->setWorkTimeRatio(1.0); + $user->setInitialLeaveBalance(0.0); + $this->em->persist($user); + $this->em->flush(); + + return $user; + } + + private function seedPreviousBalance(User $user, float $acquired, float $acquiring, float $taken): void + { + $balance = new AbsenceBalance(); + $balance->setUser($user); + $balance->setType(AbsenceType::PaidLeave); + $balance->setPeriod('2025-2026'); + $balance->setAcquired($acquired); + $balance->setAcquiring($acquiring); + $balance->setTaken($taken); + $this->em->persist($balance); + $this->em->flush(); + } + + private function runForJune2026(): void + { + $command = self::getContainer()->get(AccrueLeaveCommand::class); + $tester = new CommandTester($command); + $tester->execute(['--month' => '2026-06']); + self::assertSame(0, $tester->getStatusCode()); + } +}