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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user