# نوبت منقضی‌شده نباید قابل پرداخت باشد (پنجرهٔ ۱۵ دقیقه)

## پروژه

`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}`.
