Files
clinicpro/.claude/prompt/payment-unified-status-canceled-and-managed-hosts.md
T
hamedandClaude Opus 4.8 492a7df989 feat(payment): canceled status, manageable origin allowlist, CORS subdomains
Unify and harden the payment flow (same API for the main site and all
consumer sites; per-client difference is only frontend_address).

- Payment gains STATUS_CANCELED. Gateways distinguish user-cancel from
  failure (Mellat ResCode=17, SEP CanceledByUser, mock cancel=1) via a new
  PaymentVerifyResult::canceled flag; callback sets canceled vs failed and
  skips the circuit-breaker on cancel.
- Expiry job now cancels the pending payment when a booking lapses
  (AppointmentExpiryService + PaymentRepository::findPendingByAppointment).
- frontend_address allowlist is read from the payment_allowed_frontend_hosts
  site setting (manageable via PATCH /api/v1/admin/settings), falling back to
  the ALLOWED_FRONTEND_HOSTS env var — so a new consumer site needs no code
  change.
- .env: broaden CORS_ALLOW_ORIGIN to city subdomains (*.localhost /
  *.clinic-pro.ddev.site) and add yazd-nobat.localhost to ALLOWED_FRONTEND_HOSTS.
- Update docs/api/payment.md and docs/api/admin.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 10:06:43 +03:30

157 lines
12 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.
# یکپارچه‌سازی منطق پرداخت: وضعیت canceled + allowlist دامنه‌ی مدیریت‌پذیر + مستندسازی
## پروژه
`clinicpro` (Backend — منبع حقیقت).
> **Cross-repo (مصرف):** سایت عمومی `nobat724_front` و هر سایت سرویس‌گیرنده‌ی دیگر، همین API پرداخت را مصرف می‌کنند. قرارداد عمومی: `POST /api/v1/payment/appointment`، `POST|GET /api/v1/payment/callback/{gateway}`، `GET /api/v1/payment/{uuid}`. سایت مبدأ با `frontend_address` تعیین می‌شود و کاربر پس از پرداخت با `?payment_uuid=<uuid>&status=<status>` به همان آدرس بازگردانده می‌شود. تغییرات این پرامپت قرارداد را **نمی‌شکند** (فقط وضعیت `canceled` و allowlist مدیریت‌پذیر اضافه می‌شود).
## زمینه
زیرساخت پرداخت بک‌اند از قبل یکپارچه و کامل است: هر تراکنش یک `order_id` یکتا دارد؛ در حالت تست (`payment_test_mode=1`) درگاه `MockGateway` بدون ارتباط با بانک پرداخت را شبیه‌سازی و `success` ثبت می‌کند؛ `callback` با `verify` نتیجه را تأیید و بسته به `type` (appointment/subscription/sms_wallet) عملیات بعدی را انجام می‌دهد و سپس کاربر را با `redirectToFrontend` به سایت مبدأ بازمی‌گرداند. منطق برای همه‌ی کلاینت‌ها یکسان است چون همه همین endpointها را صدا می‌زنند.
سه فاصله با خواسته‌ی محصول باقی مانده:
1. **وضعیت `canceled`** وجود ندارد (فقط `pending`, `success`, `failed`, `refunded`). انصراف کاربر از درگاه و انقضای مهلت پرداخت باید `canceled` ثبت شود.
2. **allowlist دامنه‌ی سایت مبدأ** (`ALLOWED_FRONTEND_HOSTS`) یک رشته‌ی ثابت در `.env` است؛ افزودن هر سرویس‌گیرنده‌ی جدید نیازمند تغییر `.env` و ری‌استارت است. باید به سیستم `SiteConfig` (که از پنل ادمین قابل‌ویرایش است) منتقل شود.
3. مستندات `docs/api/payment.md` باید با وضعیت‌ها و قرارداد نهایی هم‌خوان شود.
## مشکل / هدف
۱. افزودن `Payment::STATUS_CANCELED` و ثبت آن در دو نقطه: (الف) انقضای مهلت پرداخت نوبت (در `AppointmentExpiryService`)، (ب) callbackِ انصراف کاربر از درگاه (وقتی gateway کد انصراف برمی‌گرداند، نه خطا).
۲. انتقال `allowedFrontendHosts` از `.env` به `SiteConfig` با کلید `payment_allowed_frontend_hosts`، با fallback به مقدار `.env` فعلی؛ قابل‌ویرایش از `PATCH /api/v1/admin/settings`.
۳. به‌روزرسانی `docs/api/payment.md`.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Payment/Entity/Payment.php` | افزودن `STATUS_CANCELED` + متد `cancel()` (در صورت نیاز) |
| `src/Appointment/Service/AppointmentExpiryService.php` | هنگام expire نوبت، payment ـِ pending مرتبط را `canceled` کن |
| `src/Appointment/Repository/PaymentRepository.php` یا `src/Payment/Repository/PaymentRepository.php` | متد یافتن payment ـِ pending یک نوبت |
| `src/Payment/Controller/PaymentController.php` | خواندن allowlist از `SiteConfig`؛ ثبت `canceled` در callbackِ انصراف |
| `src/Payment/Gateway/MellatGateway.php` / `SepGateway.php` / `MockGateway.php` | تشخیص «انصراف کاربر» در `verify` و تمایز آن از «خطا» |
| `src/Config/Repository/SiteConfigRepository.php` | کلید جدید `payment_allowed_frontend_hosts` |
| `docs/api/payment.md` | مستندسازی وضعیت‌ها + allowlist |
## وضعیت فعلی (کد واقعی)
### وضعیت‌های Payment (بدون canceled)
```php
public const STATUS_PENDING = 'pending';
public const STATUS_SUCCESS = 'success';
public const STATUS_FAILED = 'failed';
public const STATUS_REFUNDED = 'refunded';
```
### allowlist ثابت از .env (services.yaml: `$allowedFrontendHosts: '%env(ALLOWED_FRONTEND_HOSTS)%'`)
```php
// PaymentController
private function isAllowedFrontend(string $url): bool
{
$hosts = array_filter(array_map('trim', explode(',', $this->allowedFrontendHosts)));
if (empty($hosts)) return false;
$host = parse_url($url, PHP_URL_HOST);
return in_array($host, $hosts, true);
}
```
### expire نوبت — payment را دست نمی‌زند
```php
// AppointmentExpiryService::expireStale()
foreach ([...findPaymentExpired($now), ...findExpiredPending($now)] as $appointment) {
$appointment->transitionTo(Appointment::STATUS_EXPIRED);
$this->appointmentRepo->save($appointment, false);
// ❌ payment ـِ pending این نوبت همچنان pending می‌ماند
}
```
### callback — انصراف کاربر معادل خطا گرفته می‌شود
```php
$result = $gw?->verify($callbackData) ?? null;
if ($result === null || !$result->success) {
$payment->setStatus(Payment::STATUS_FAILED); // ❌ انصراف هم failed ثبت می‌شود، نه canceled
...
return $this->redirectToFrontend($payment, false);
}
```
> `SiteConfig` یک ذخیره‌ی key-value است: `SiteConfigRepository::get($key)` / `set($key, $value)` / `getAll()`. کلید `payment_test_mode` همین‌جاست و از پنل ادمین (`GET/PATCH /api/v1/admin/settings`) قابل‌ویرایش است.
## وظایف
### ۱. افزودن وضعیت `canceled`
در `src/Payment/Entity/Payment.php`:
```php
public const STATUS_CANCELED = 'canceled';
```
- در صورت وجود متدهای transition/setStatus، مطمئن شو `canceled` معتبر است. اگر متد `cancel()` کمکی منطقی است اضافه کن (`$this->status = self::STATUS_CANCELED;`).
- نیازی به migration نیست (ستون `status` رشته است)، مگر اینکه enum/check-constraint داشته باشد — بررسی کن.
### ۲. تمایز «انصراف» از «خطا» در gatewayها و callback
- در `verify` هر gateway، یک علامت برای «کاربر انصراف داد» اضافه کن. الگوها:
- **Mellat:** `ResCode === '17'` (انصراف کاربر) → canceled؛ سایر کدهای ناموفق → failed.
- **Sep:** `State === 'CanceledByUser'` → canceled.
- **Mock:** اگر `ResCode === '17'` یا پارامتر `cancel=1` بود → canceled (برای تست).
ساده‌ترین راه بدون شکستن `PaymentVerifyResult`: یک فیلد `canceled: bool` به `PaymentVerifyResult` اضافه کن (پیش‌فرض false)، یا یک `errorCode` که controller بر اساسش تصمیم بگیرد.
- در `PaymentController::callback`، شاخه‌ی ناموفق را به دو حالت تقسیم کن:
```php
if ($result === null || !$result->success) {
$payment->setStatus($result?->canceled ? Payment::STATUS_CANCELED : Payment::STATUS_FAILED);
$this->paymentRepo->save($payment);
if (!$result?->canceled) $this->circuitBreaker->recordFailure($gateway); // انصراف کاربر، خطای درگاه نیست
return $this->redirectToFrontend($payment, false);
}
```
### ۳. canceled هنگام انقضای مهلت پرداخت
در `AppointmentExpiryService::expireStale()`، هنگام expire هر نوبت، payment ـِ `pending` مرتبط را `canceled` کن:
```php
$appointment->transitionTo(Appointment::STATUS_EXPIRED);
$this->appointmentRepo->save($appointment, false);
$payment = $this->paymentRepo->findPendingByAppointment($appointment);
if ($payment !== null) {
$payment->setStatus(Payment::STATUS_CANCELED);
$this->paymentRepo->save($payment, false);
}
```
- متد `findPendingByAppointment(Appointment): ?Payment` را به `PaymentRepository` اضافه کن (status=pending AND appointment=...).
- `AppointmentExpiryService` را با `PaymentRepository` تزریق کن.
- این سرویس از طریق Scheduler هر ۱ دقیقه اجرا می‌شود (همان مکانیزم موجود)، پس canceledها خودکار ثبت می‌شوند.
### ۴. allowlist مدیریت‌پذیر از SiteConfig
- در `PaymentController::isAllowedFrontend`، منبع hostها را اول از `SiteConfig` بخوان و اگر خالی بود به `.env` (`$this->allowedFrontendHosts`) fallback کن:
```php
private function allowedHosts(): array
{
$fromConfig = (string) ($this->configRepo->get('payment_allowed_frontend_hosts') ?? '');
$raw = $fromConfig !== '' ? $fromConfig : $this->allowedFrontendHosts;
return array_filter(array_map('trim', explode(',', $raw)));
}
private function isAllowedFrontend(string $url): bool
{
$hosts = $this->allowedHosts();
if (empty($hosts)) return false;
return in_array(parse_url($url, PHP_URL_HOST), $hosts, true);
}
```
- مطمئن شو کلید `payment_allowed_frontend_hosts` در فهرست کلیدهای مجازِ `PATCH /api/v1/admin/settings` هست (اگر آن endpoint allowlist کلید دارد، این کلید را اضافه کن). بررسی کن `SiteConfigController::patch` چطور کلیدهای مجاز را محدود می‌کند.
- مقدار اولیه را در `.env` نگه‌دار (fallback)؛ مدیر می‌تواند از پنل override کند.
### ۵. مستندسازی `docs/api/payment.md`
- وضعیت‌های تراکنش: `pending` / `success` / `failed` / `canceled` / `refunded` با توضیح هر کدام (canceled = انصراف کاربر یا انقضای مهلت).
- جریان callback و redirect به سایت مبدأ با `?payment_uuid=<uuid>&status=<status>`.
- حالت تست (Sandbox) با `MockGateway` و نحوه‌ی فعال‌سازی (`payment_test_mode` در تنظیمات).
- allowlist دامنه‌ی سایت مبدأ و اینکه از پنل ادمین (`payment_allowed_frontend_hosts`) قابل‌ویرایش است.
## نکات مهم
- **منطق برای همه‌ی کلاینت‌ها یکسان است** و باید بماند؛ هیچ شاخه‌ی if خاصِ «سایت اصلی» در برابر «سرویس‌گیرنده» اضافه نکن. تنها تفاوت، `frontend_address`ِ هر کلاینت است که در تراکنش ذخیره و در پایان برای redirect استفاده می‌شود.
- `frontend_address` همان «سایت مبدأ» است؛ allowlist فقط برای جلوگیری از Open Redirect است — منطق redirect عوض نمی‌شود.
- وضعیت `canceled` نباید circuit-breaker درگاه را به‌عنوان failure ثبت کند (انصراف کاربر، نقص درگاه نیست).
- تاریخ‌ها Unix timestamp؛ پاسخ‌ها از `BaseController` (`$this->success/$this->error`)؛ مبالغ بر حسب ریال.
- بعد از تغییر: `ddev exec php -l` روی فایل‌های PHP؛ `cache:clear`؛ اگر `status` ستون enum/constraint داشت `migrations:diff`/`migrate`.
- تست رفتاری: (الف) جریان تست موفق (mock) → `success` + نوبت confirmed؛ (ب) انصراف (mock با `cancel=1`/`ResCode=17`) → `canceled` + نوبت دست‌نخورده؛ (ج) انقضای مهلت → Scheduler نوبت را `expired` و payment را `canceled` کند؛ (د) `frontend_address` با دامنه‌ی اضافه‌شده در `SiteConfig` پذیرفته شود و با دامنه‌ی نامجاز ۴۲۲ بدهد.
- طبق Standing Rule، `docs/api/payment.md` در همین session به‌روز شود.