# نوبت منقضی‌شده نباید قابل پرداخت باشد (پنجرهٔ ۱۵ دقیقه) ## پروژه `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` ```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، بدون چک زمان) ```php $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` (نوبت را اصلاً چک نمی‌کند) ```php $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` ندارد) ```php $labels = [ 'success' => [...], 'failed' => [...], 'canceled' => [...], 'pending' => [...], 'notfound' => [...], 'invalid' => [...], 'gateway' => [...], 'invalid_return' => [...], 'forbidden' => [...], ]; ``` ### الگوی موجود انقضا (`AppointmentExpiryService`) — برای مرجع ```php $appointment->transitionTo(Appointment::STATUS_EXPIRED); $this->appointmentRepo->save($appointment, false); $payment->setStatus(Payment::STATUS_CANCELED); $this->paymentRepo->save($payment, false); ``` ## وظایف ### ۱. helper انقضای پنجرهٔ پرداخت روی `Appointment` ```php /** آیا پنجرهٔ ۱۵ دقیقه‌ایِ پرداخت گذشته یا زمان اسلات رد شده است؟ */ public function isPaymentWindowExpired(int $now): bool { if ($this->expiresAt !== null && $now > $this->expiresAt) { return true; } return $now >= $this->slotStart; } ``` > `expiresAt` منبع اصلیِ مهلت ۱۵ دقیقه است؛ چک اسلات هم برای نوبت‌هایی که زمانشان رسیده اضافه شده (هم‌راستا با `findExpiredPending`). ### ۲. برچسب `expired` در `renderPaymentResult` ```php 'expired' => ['مهلت پرداخت به پایان رسید', 'مهلت ۱۵ دقیقه‌ایِ پرداخت این نوبت تمام شده است. لطفاً دوباره نوبت بگیرید.'], ``` ### ۳. گارد انقضا در `startOrderPayment` بعد از `findByUuid` و قبل از/همراه با چک status، انقضای زمانی را هم لحاظ کن و نوبتِ منقضی را در جا expire کن: ```php $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` باید نوبت را چک کنند: ```php $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`: ```php 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}`.