# یکپارچه‌سازی منطق پرداخت: وضعیت 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=&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=&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 به‌روز شود.