Files
clinicpro/.claude/prompt/expired-appointment-blocks-payment.md
T

188 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# نوبت منقضی‌شده نباید قابل پرداخت باشد (پنجرهٔ ۱۵ دقیقه)
## پروژه
`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}`.