feat(payment): implement expiration handling for appointment payments
This commit is contained in:
@@ -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}`.
|
||||
@@ -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`.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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] ?? ['خطا در پرداخت', 'خطایی در فرآیند پرداخت رخ داد.'];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user