feat(appointment): auto-expire unpaid bookings via Symfony Scheduler

Run the 15-min payment-expiry every minute through Symfony Scheduler so
unpaid pending bookings flip to expired without a system crontab. Install
symfony/scheduler; extract the expiry logic into AppointmentExpiryService
(reused by the existing command); add ExpireAppointmentsMessage + handler
and an #[AsSchedule] provider (RecurringMessage::every 1 minute); wire a
scheduler_default transport in messenger.yaml. Slots already free
just-in-time via isSlotTaken, so this only syncs the DB status.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-06-15 22:43:50 +03:30
co-authored by Claude Opus 4.8
parent a348dcae5f
commit 71ab8892c9
12 changed files with 400 additions and 23 deletions
@@ -2,8 +2,7 @@
namespace App\Appointment\Command;
use App\Appointment\Entity\Appointment;
use App\Appointment\Repository\AppointmentRepository;
use App\Appointment\Service\AppointmentExpiryService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
@@ -15,31 +14,14 @@ use Symfony\Component\Console\Output\OutputInterface;
)]
class CancelExpiredAppointmentsCommand extends Command
{
public function __construct(private readonly AppointmentRepository $appointmentRepo)
public function __construct(private readonly AppointmentExpiryService $expiryService)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$now = time();
$expired = [];
foreach ([...$this->appointmentRepo->findPaymentExpired($now), ...$this->appointmentRepo->findExpiredPending($now)] as $appointment) {
$expired[$appointment->getUuid()] = $appointment;
}
$count = 0;
foreach ($expired as $appointment) {
$appointment->transitionTo(Appointment::STATUS_EXPIRED);
$this->appointmentRepo->save($appointment, false);
$count++;
}
if ($count > 0) {
$this->appointmentRepo->save(reset($expired)); // flush once
}
$count = $this->expiryService->expireStale();
$output->writeln(sprintf('Expired %d appointments.', $count));
return Command::SUCCESS;
}
@@ -0,0 +1,7 @@
<?php
namespace App\Appointment\Message;
final class ExpireAppointmentsMessage
{
}
@@ -0,0 +1,18 @@
<?php
namespace App\Appointment\MessageHandler;
use App\Appointment\Message\ExpireAppointmentsMessage;
use App\Appointment\Service\AppointmentExpiryService;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class ExpireAppointmentsHandler
{
public function __construct(private readonly AppointmentExpiryService $expiryService) {}
public function __invoke(ExpireAppointmentsMessage $message): void
{
$this->expiryService->expireStale();
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Appointment\Schedule;
use App\Appointment\Message\ExpireAppointmentsMessage;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
#[AsSchedule('appointment_expiry')]
final class ExpireAppointmentsSchedule implements ScheduleProviderInterface
{
public function getSchedule(): Schedule
{
return (new Schedule())->add(
RecurringMessage::every('1 minute', new ExpireAppointmentsMessage())
);
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Appointment\Service;
use App\Appointment\Entity\Appointment;
use App\Appointment\Repository\AppointmentRepository;
class AppointmentExpiryService
{
public function __construct(private readonly AppointmentRepository $appointmentRepo) {}
/**
* Expire pending bookings whose payment window has lapsed or whose slot
* time has already passed. Idempotent — safe to run repeatedly.
*
* @return int number of appointments expired
*/
public function expireStale(): int
{
$now = time();
$expired = [];
foreach ([...$this->appointmentRepo->findPaymentExpired($now), ...$this->appointmentRepo->findExpiredPending($now)] as $appointment) {
$expired[$appointment->getUuid()] = $appointment;
}
$count = 0;
foreach ($expired as $appointment) {
$appointment->transitionTo(Appointment::STATUS_EXPIRED);
$this->appointmentRepo->save($appointment, false);
$count++;
}
if ($count > 0) {
$this->appointmentRepo->save(reset($expired)); // flush once
}
return $count;
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\Schedule as SymfonySchedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
use Symfony\Contracts\Cache\CacheInterface;
#[AsSchedule]
class Schedule implements ScheduleProviderInterface
{
public function __construct(
private CacheInterface $cache,
) {
}
public function getSchedule(): SymfonySchedule
{
return (new SymfonySchedule())
->stateful($this->cache) // ensure missed tasks are executed
->processOnlyLastMissedRun(true) // ensure only last missed task is run
// add your own tasks here
// see https://symfony.com/doc/current/scheduler.html#attaching-recurring-messages-to-a-schedule
;
}
}