feat(appointment): expire pending bookings past their payment window

Add findPaymentExpired (pending with expires_at < now) and have the
cancel-expired command flip both payment-expired and slot-time-passed
pendings to expired (deduped). Run it on a schedule (e.g. every minute:
* * * * * php bin/console app:cancel-expired-appointments) to free locks
held by unpaid bookings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-06-15 17:58:28 +03:30
co-authored by Claude Opus 4.8
parent aa2e46dfdb
commit 089badbb5a
2 changed files with 22 additions and 4 deletions
@@ -11,7 +11,7 @@ use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(
name: 'app:cancel-expired-appointments',
description: 'Marks pending appointments whose slot_start is in the past as expired',
description: 'Expires pending bookings whose 15-min payment window lapsed or whose slot time has passed',
)]
class CancelExpiredAppointmentsCommand extends Command
{
@@ -22,9 +22,14 @@ class CancelExpiredAppointmentsCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): int
{
$expired = $this->appointmentRepo->findExpiredPending(time());
$count = 0;
$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);
@@ -32,7 +37,7 @@ class CancelExpiredAppointmentsCommand extends Command
}
if ($count > 0) {
$this->appointmentRepo->save($expired[0]); // flush once
$this->appointmentRepo->save(reset($expired)); // flush once
}
$output->writeln(sprintf('Expired %d appointments.', $count));
@@ -80,6 +80,19 @@ class AppointmentRepository extends ServiceEntityRepository
return $this->findBy($criteria, ['slotStart' => 'DESC']);
}
/** @return Appointment[] pending bookings whose 15-minute payment window has lapsed */
public function findPaymentExpired(int $now): array
{
return $this->createQueryBuilder('a')
->where('a.status = :status')
->andWhere('a.expiresAt IS NOT NULL')
->andWhere('a.expiresAt < :now')
->setParameter('status', Appointment::STATUS_PENDING)
->setParameter('now', $now)
->getQuery()
->getResult();
}
/** @return Appointment[] pending appointments older than given timestamp */
public function findExpiredPending(int $before): array
{