From 34d521434fe8ae80d321c06318bed879c8adf627 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 3 Jul 2026 09:53:55 +0330 Subject: [PATCH] feat(payment): implement expiration handling for appointment payments --- .../expired-appointment-blocks-payment.md | 187 ++++++++++++++++++ docs/api/payment.md | 2 + src/Appointment/Entity/Appointment.php | 9 + src/Payment/Controller/PaymentController.php | 34 ++++ 4 files changed, 232 insertions(+) create mode 100644 .claude/prompt/expired-appointment-blocks-payment.md diff --git a/.claude/prompt/expired-appointment-blocks-payment.md b/.claude/prompt/expired-appointment-blocks-payment.md new file mode 100644 index 00000000..96f3c85b --- /dev/null +++ b/.claude/prompt/expired-appointment-blocks-payment.md @@ -0,0 +1,187 @@ +# نوبت منقضی‌شده نباید قابل پرداخت باشد (پنجرهٔ ۱۵ دقیقه) + +## پروژه + +`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}`. diff --git a/docs/api/payment.md b/docs/api/payment.md index 5b80ad0d..fa5dc5d2 100644 --- a/docs/api/payment.md +++ b/docs/api/payment.md @@ -226,6 +226,7 @@ Initiate payment for an appointment. Returns a redirect URL to the payment gatew ### رفتار - نوبت یافت نشد → `302` به `return?status=notfound` (یا `422` اگر return نبود/نامجاز). +- **نوبت منقضی** (`status=expired` یا گذشتنِ پنجرهٔ ۱۵ دقیقه‌ای `expires_at`/زمان اسلات) → نوبت `expired` و پرداخت pending آن `canceled` می‌شود و `302` `return?status=expired` (پیام «مهلت پرداخت به پایان رسید»). - نوبت قابل‌پرداخت نیست (نه `pending`/`confirmed`) → `302` `return?status=invalid`. - درگاه نامعتبر/غیرفعال → `302` `return?status=gateway`. - موفق → ساخت/ادامهٔ `Payment` pending (بدون pending تکراری via `findPendingByAppointment`) و `302` به بانک (یا فرم auto-submit POST برای ملت). @@ -248,6 +249,7 @@ Initiate payment for an appointment. Returns a redirect URL to the payment gatew ### رفتار - اگر پرداخت یافت نشد → `404 { success:false, message:"payment not found" }`. +- **پرداختِ نوبت با نوبتِ منقضی** (نوع `appointment` و نوبت `expired` یا گذشتنِ پنجرهٔ ۱۵ دقیقه‌ای): نوبت `expired` و پرداخت pending آن `canceled` می‌شود و صفحهٔ نتیجهٔ `expired` («مهلت پرداخت به پایان رسید») رندر می‌شود — به بانک نمی‌رود. - اگر وضعیت پرداخت `pending` نباشد → `302` به `frontend_address` با نتیجه (جلوگیری از پرداخت تکراری). - درگاه resolve می‌شود (در حالت تست → mock)؛ اگر نامعتبر بود یا circuit breaker باز بود → پرداخت `failed` و `302` به `frontend_address`. - `gateway->initiate(...)` صدا زده می‌شود (ارتباط با بانک). در صورت شکست → `failed` و `302` به `frontend_address`. diff --git a/src/Appointment/Entity/Appointment.php b/src/Appointment/Entity/Appointment.php index 20bf91fa..82fc813f 100644 --- a/src/Appointment/Entity/Appointment.php +++ b/src/Appointment/Entity/Appointment.php @@ -170,6 +170,15 @@ class Appointment return $this; } + /** آیا پنجرهٔ ۱۵ دقیقه‌ایِ پرداخت گذشته یا زمان اسلات رد شده است؟ */ + public function isPaymentWindowExpired(int $now): bool + { + if ($this->expiresAt !== null && $now > $this->expiresAt) { + return true; + } + return $now >= $this->slotStart; + } + public function canTransitionTo(string $newStatus): bool { return in_array($newStatus, self::ALLOWED_TRANSITIONS[$this->status] ?? [], true); diff --git a/src/Payment/Controller/PaymentController.php b/src/Payment/Controller/PaymentController.php index 9e588833..b6699dd1 100644 --- a/src/Payment/Controller/PaymentController.php +++ b/src/Payment/Controller/PaymentController.php @@ -155,6 +155,14 @@ class PaymentController extends BaseController return $this->redirectToReturn($return, 'notfound'); } + // نوبتِ منقضی (status=expired یا پنجرهٔ ۱۵ دقیقه‌ای گذشته) → غیرقابل‌پرداخت. + if ($appointment->getStatus() === Appointment::STATUS_EXPIRED + || ($appointment->getStatus() === Appointment::STATUS_PENDING + && $appointment->isPaymentWindowExpired(time()))) { + $this->expireAppointmentPayment($appointment); + return $this->redirectToReturn($return, 'expired'); + } + // اعتبارسنجی سفارش: فقط نوبت قابل‌پرداخت. if (!in_array($appointment->getStatus(), [Appointment::STATUS_PENDING, Appointment::STATUS_CONFIRMED], true)) { return $this->redirectToReturn($return, 'invalid'); @@ -194,6 +202,21 @@ class PaymentController extends BaseController return $payment; } + /** نوبتِ منقضی را expired و پرداخت pending آن را canceled می‌کند (idempotent). */ + 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); + } + } + private function redirectToReturn(string $return, string $status): \Symfony\Component\HttpFoundation\Response { if ($return !== '' && $this->isAllowedFrontend($return)) { @@ -224,6 +247,16 @@ class PaymentController extends BaseController return $this->renderPaymentResult('notfound'); } + // پرداختِ نوبت: اگر نوبت منقضی شده باشد، پرداخت هم منقضی و غیرقابل‌پرداخت است. + $appointment = $payment->getAppointment(); + if ($payment->getType() === Payment::TYPE_APPOINTMENT && $appointment !== null + && ($appointment->getStatus() === Appointment::STATUS_EXPIRED + || ($appointment->getStatus() === Appointment::STATUS_PENDING + && $appointment->isPaymentWindowExpired(time())))) { + $this->expireAppointmentPayment($appointment); + return $this->renderPaymentResult('expired', $payment); + } + // فقط پرداخت در انتظار قابل انتقال به درگاه است (جلوگیری از پرداخت تکراری/replay). if ($payment->getStatus() !== Payment::STATUS_PENDING) { return $this->redirectToFrontend($payment, $payment->getStatus() === Payment::STATUS_SUCCESS); @@ -558,6 +591,7 @@ class PaymentController extends BaseController 'gateway' => ['درگاه نامعتبر', 'درگاه پرداخت انتخابی نامعتبر یا غیرفعال است.'], 'invalid_return' => ['آدرس بازگشت نامعتبر', 'آدرس بازگشت مجاز نیست.'], 'forbidden' => ['دسترسی غیرمجاز', 'این درخواست از مبدأ مجاز ارسال نشده است.'], + 'expired' => ['مهلت پرداخت به پایان رسید', 'مهلت ۱۵ دقیقه‌ایِ پرداخت این نوبت تمام شده است. لطفاً دوباره نوبت بگیرید.'], ]; [$title, $message] = $labels[$status] ?? ['خطا در پرداخت', 'خطایی در فرآیند پرداخت رخ داد.'];