10 KiB
نوبت منقضیشده نباید قابل پرداخت باشد (پنجرهٔ ۱۵ دقیقه)
پروژه
clinicpro (backend / Payment + Appointment)
زمینه
هر نوبتِ رزروشده فقط ۱۵ دقیقه (Appointment::PAYMENT_TTL = 900) مهلت پرداخت دارد؛ Appointment.expiresAt زمان انقضای این پنجره است. یک cron (app:cancel-expired-appointments → AppointmentExpiryService::expireStale) نوبتهای منقضی را به expired و پرداخت pending آنها را به canceled میبرد، ولی هر ۱ دقیقه اجرا میشود؛ پس یک نوبت میتواند «هنوز pending ولی پنجرهاش تمامشده» باشد تا وقتی cron برسد.
مشکل: کاربر میتواند در این فاصله همچنان پرداخت را شروع/ادامه دهد:
GET /api/v1/payment/order/{appointmentUuid}(startOrderPayment): فقطstatus ∈ {pending, confirmed}را چک میکند، نه انقضای زمانی.GET /api/v1/payment/pay/{orderId}(pay): فقطpayment.status === pendingرا چک میکند، اصلاً نوبت را نمیبیند → نوبتِ منقضی هم به بانک میرود.
مشکل / هدف
اگر نوبت (پرداخت از نوع appointment) منقضی شده باشد — چه با status === expired و چه با گذشتنِ expiresAt/زمانِ اسلات ولی هنوز pending — پرداخت باید منقضی و غیرقابلپرداخت شود: پرداخت pending مربوطه canceled شود، نوبت (اگر هنوز pending است) expired شود، و به کاربر صفحهٔ «مهلت پرداخت تمام شد» نمایش/بازگشت داده شود. هر دو ورودی (order و pay) باید این را رعایت کنند.
فایلهای مرتبط
| فایل | نقش |
|---|---|
clinicpro/src/Appointment/Entity/Appointment.php |
helper isPaymentWindowExpired(int $now) |
clinicpro/src/Payment/Controller/PaymentController.php |
گارد انقضا در startOrderPayment و pay + برچسب expired |
clinicpro/docs/api/payment.md |
مستندسازی رفتار انقضا |
وضعیت فعلی
Appointment.php
public const PAYMENT_TTL = 900; // 15 minutes to pay before a pending booking expires
// ...
#[ORM\Column(name: 'expires_at', type: 'integer', nullable: true)]
private ?int $expiresAt = null;
public function getExpiresAt(): ?int { return $this->expiresAt; }
public function getSlotStart(): int { return $this->slotStart; }
public function getStatus(): string { /* ... */ }
public function canTransitionTo(string $newStatus): bool { /* ... */ }
public function transitionTo(string $newStatus): self { /* ... */ }
// STATUS_PENDING / STATUS_CONFIRMED / STATUS_EXPIRED
PaymentController::startOrderPayment (فقط status، بدون چک زمان)
$appointment = $this->appointmentRepo->findByUuid($appointmentUuid);
if ($appointment === null) {
return $this->redirectToReturn($return, 'notfound');
}
// اعتبارسنجی سفارش: فقط نوبت قابلپرداخت.
if (!in_array($appointment->getStatus(), [Appointment::STATUS_PENDING, Appointment::STATUS_CONFIRMED], true)) {
return $this->redirectToReturn($return, 'invalid');
}
PaymentController::pay (نوبت را اصلاً چک نمیکند)
$payment = $this->paymentRepo->findByOrderId($orderId);
if ($payment === null) {
return $this->renderPaymentResult('notfound');
}
if ($payment->getStatus() !== Payment::STATUS_PENDING) {
return $this->redirectToFrontend($payment, $payment->getStatus() === Payment::STATUS_SUCCESS);
}
$result = $this->paymentManager->startGatewayHandoff($payment);
renderPaymentResult labels (برچسب expired ندارد)
$labels = [
'success' => [...], 'failed' => [...], 'canceled' => [...],
'pending' => [...], 'notfound' => [...], 'invalid' => [...],
'gateway' => [...], 'invalid_return' => [...], 'forbidden' => [...],
];
الگوی موجود انقضا (AppointmentExpiryService) — برای مرجع
$appointment->transitionTo(Appointment::STATUS_EXPIRED);
$this->appointmentRepo->save($appointment, false);
$payment->setStatus(Payment::STATUS_CANCELED);
$this->paymentRepo->save($payment, false);
وظایف
۱. helper انقضای پنجرهٔ پرداخت روی Appointment
/** آیا پنجرهٔ ۱۵ دقیقهایِ پرداخت گذشته یا زمان اسلات رد شده است؟ */
public function isPaymentWindowExpired(int $now): bool
{
if ($this->expiresAt !== null && $now > $this->expiresAt) {
return true;
}
return $now >= $this->slotStart;
}
expiresAtمنبع اصلیِ مهلت ۱۵ دقیقه است؛ چک اسلات هم برای نوبتهایی که زمانشان رسیده اضافه شده (همراستا باfindExpiredPending).
۲. برچسب expired در renderPaymentResult
'expired' => ['مهلت پرداخت به پایان رسید', 'مهلت ۱۵ دقیقهایِ پرداخت این نوبت تمام شده است. لطفاً دوباره نوبت بگیرید.'],
۳. گارد انقضا در startOrderPayment
بعد از findByUuid و قبل از/همراه با چک status، انقضای زمانی را هم لحاظ کن و نوبتِ منقضی را در جا expire کن:
$appointment = $this->appointmentRepo->findByUuid($appointmentUuid);
if ($appointment === null) {
return $this->redirectToReturn($return, 'notfound');
}
// نوبتِ منقضی (status=expired یا پنجرهٔ زمانی گذشته) → غیرقابلپرداخت.
if ($appointment->getStatus() === Appointment::STATUS_EXPIRED
|| ($appointment->getStatus() === Appointment::STATUS_PENDING
&& $appointment->isPaymentWindowExpired(time()))) {
$this->expireAppointmentPayment($appointment); // helper تسک ۵
return $this->redirectToReturn($return, 'expired');
}
if (!in_array($appointment->getStatus(), [Appointment::STATUS_PENDING, Appointment::STATUS_CONFIRMED], true)) {
return $this->redirectToReturn($return, 'invalid');
}
۴. گارد انقضا در pay
پرداختهای نوع appointment باید نوبت را چک کنند:
$payment = $this->paymentRepo->findByOrderId($orderId);
if ($payment === null) {
return $this->renderPaymentResult('notfound');
}
// نوبتِ منقضی → پرداخت هم منقضی و غیرقابلپرداخت.
$appointment = $payment->getAppointment();
if ($payment->getType() === Payment::TYPE_APPOINTMENT && $appointment !== null) {
if ($appointment->getStatus() === Appointment::STATUS_EXPIRED
|| ($appointment->getStatus() === Appointment::STATUS_PENDING
&& $appointment->isPaymentWindowExpired(time()))) {
$this->expireAppointmentPayment($appointment);
return $this->renderPaymentResult('expired', $payment);
}
}
if ($payment->getStatus() !== Payment::STATUS_PENDING) {
return $this->redirectToFrontend($payment, $payment->getStatus() === Payment::STATUS_SUCCESS);
}
// ... ادامهٔ handoff
۵. helper مشترک expireAppointmentPayment
یک متد خصوصی در کنترلر که نوبت را (اگر هنوز pending) expired و پرداخت pending آن را canceled کند — idempotent، همراستا با AppointmentExpiryService:
private function expireAppointmentPayment(Appointment $appointment): void
{
if ($appointment->getStatus() === Appointment::STATUS_PENDING
&& $appointment->canTransitionTo(Appointment::STATUS_EXPIRED)) {
$appointment->transitionTo(Appointment::STATUS_EXPIRED);
$this->appointmentRepo->save($appointment);
}
$payment = $this->paymentRepo->findPendingByAppointment($appointment);
if ($payment !== null) {
$payment->setStatus(Payment::STATUS_CANCELED);
$this->paymentRepo->save($payment);
}
}
اگر
appointmentRepo/paymentRepoدر کنترلر تزریق نشدهاند، بررسی کن (احتمالاً هستند چونstartOrderPaymentاز هر دو استفاده میکند).
نکات مهم
- دو مسیر، یک منطق: هم
order(شروع) و همpay(انتقال به بانک) باید گارد را داشته باشند؛ کاربر ممکن است مستقیم رویpay/{orderId}قدیمی برود. - idempotent: اگر نوبت قبلاً
expiredیا پرداختcanceledشده، helper نباید خطا بزند (فقط pendingها را transition کن؛canTransitionToرا چک کن). - فقط نوع appointment: پرداختهای
subscription/sms_walletنوبت ندارند؛ گاردpayرا باgetType() === TYPE_APPOINTMENT && appointment !== nullمحدود کن. - بازگشت به فرانتاند:
redirectToReturn/renderPaymentResultبا وضعیتexpired؛ فرانتnobat724_frontوضعیت را از query (status=expired) یا صفحهٔ نتیجه میگیرد — مطمئن شو رشتهٔexpiredبا هندلینگ فعلی سازگار است (اگر فرانت فقط چند وضعیت خاص را میشناسد،expiredرا هم پیام مناسب بدهد؛ در غیر اینصورت پیام پیشفرض «ناموفق» نمایش داده میشود). - callback: اگر کاربر همان لحظه پرداخت را در بانک کامل کند (نوبت منقضی)،
processCallbackجداگانه با قفل + idempotent مدیریت میشود؛ این تغییر فقط شروع/انتقال را میبندد. (اگر خواستی سختگیرانهتر شود، درverifyهم میتوان نوبت منقضی را رد کرد — ولی خارج از این تسک.) - تاریخها Unix timestamp؛
time()سرور. - بعد از تغییر
src/Payment/*→docs/api/payment.mdرا بهروز کن (رفتارexpiredبرایorderوpay). - تست:
ddev exec php -l ...،cache:clear، و یک تست دستی: پرداختِ appointment بساز،expires_atرا در DB به گذشته ست کن، سپسGET /api/v1/payment/pay/{orderId}→ باید صفحهٔ «مهلت پرداخت تمام شد» بدهد و پرداختcanceled+ نوبتexpiredشود؛ همینطورGET /api/v1/payment/order/{uuid}.