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

10 KiB
Raw Permalink Blame History

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

پروژه

clinicpro (backend / Payment + Appointment)

زمینه

هر نوبتِ رزروشده فقط ۱۵ دقیقه (Appointment::PAYMENT_TTL = 900) مهلت پرداخت دارد؛ Appointment.expiresAt زمان انقضای این پنجره است. یک cron (app:cancel-expired-appointmentsAppointmentExpiryService::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}.