# فعال‌سازی درگاه sandbox بانک ملت (banktest.ir) — موقت ## پروژه `clinicpro` (backend / Payment) > **مهم:** این تغییر **موقتی** است و باید روی یک **branch جدا** انجام شود (مثلاً `feature/mellat-sandbox`). هدف: تست کامل چرخهٔ پرداخت ملت بدون درگاه واقعی، با استفاده از sandbox شرکت banktest.ir که SOAP/REST ملت را شبیه‌سازی می‌کند. بعد از اتمام تست باید به‌راحتی برگردانده شود؛ پس تغییرات را کمینه و برگشت‌پذیر نگه دار (یک فلگ روشن/خاموش). ## زمینه `MellatGateway` فعلاً روی endpoint **عملیاتی** ملت هاردکد است: - SOAP service: `https://bpm.shaparak.ir/pgwchannel/services/pgw` - فرم پرداخت: `https://bpm.shaparak.ir/pgwchannel/startpay.mellat` تست واقعی روی سرور بدون ترمینال/دامنهٔ ثبت‌شده ممکن نیست (خطای ۶۲ / ۲۱ / ۲۴). banktest.ir یک sandbox می‌دهد که **همان کانال SOAP `pgwchannel`** ملت را روی دامنهٔ خودش mirror می‌کند و credentials آزمایشی می‌دهد. چون کد فعلی ما SOAP روی `pgwchannel` است، فقط با **سوییچ base-URL + credentials** به sandbox وصل می‌شود — بدون بازنویسی منطق XML/parse. ### اطلاعات sandbox (banktest.ir) | مورد | مقدار | |------|-------| | terminalId | `134759344` | | userName | `user134759344` | | userPassword | `17384843` | | WSDL | `https://sandbox.banktest.ir/mellat/bpm.shaparak.ir/pgwchannel/services/pgw?wsdl` | | SOAP service (بدون `?wsdl`) | `https://sandbox.banktest.ir/mellat/bpm.shaparak.ir/pgwchannel/services/pgw` | | فرم پرداخت | `https://sandbox.banktest.ir/mellat/bpm.shaparak.ir/pgwchannel/startpay.mellat` | > نکته: sandbox متد REST هم دارد (`.../ipg2/rest/...`) و «جدیدترین روش» است، ولی برای کمینه‌کردن تغییر و برگشت‌پذیری، **از همان مسیر SOAP `pgwchannel` استفاده کن** که کد فعلی با آن سازگار است. REST را پیاده نکن مگر اینکه SOAP sandbox کار نکند. ## مشکل / هدف یک فلگ تنظیماتی `mellat_sandbox` اضافه کن که وقتی `'1'` است: 1. `MellatGateway` به base-URLهای sandbox banktest.ir وصل شود (SOAP service + فرم پرداخت). 2. credentials از مقادیر sandbox بالا استفاده شوند (به‌صورت fallback، تا نیازی به دست‌کاری تنظیمات prod نباشد). 3. درگاه `mellat` به‌عنوان درگاه قابل‌انتخاب و «configured» شناخته شود، **بدون** رفتن به `payment_test_mode` (که `MockGateway` را اجبار می‌کند و اصلاً به بانک وصل نمی‌شود). 4. CSP اجازهٔ `form-action` به `sandbox.banktest.ir` بدهد تا فرم auto-submit به sandbox باز شود. خاموش‌بودن فلگ = رفتار دقیقاً مثل قبل (prod). ## فایل‌های مرتبط | فایل | نقش | |------|-----| | `clinicpro/src/Payment/Gateway/MellatGateway.php` | سوییچ base-URL + credentials sandbox | | `clinicpro/src/Payment/Gateway/GatewayFactory.php` | اجازهٔ استفاده از mellat در حالت sandbox (بدون Mock) | | `clinicpro/src/Shared/EventSubscriber/SecurityHeadersSubscriber.php` | افزودن `sandbox.banktest.ir` به `form-action` | | `clinicpro/src/Config/Controller/SiteConfigController.php` | افزودن `mellat_sandbox` به `ALLOWED_KEYS` (تا از admin settings toggle شود) | | `clinicpro/docs/api/payment.md` | مستندسازی حالت sandbox | ## وضعیت فعلی ### `MellatGateway.php` (خطوط کلیدی) ```php class MellatGateway implements PaymentGatewayInterface { private const PAYMENT_URL = 'https://bpm.shaparak.ir/pgwchannel/startpay.mellat'; private const SERVICE_URL = 'https://bpm.shaparak.ir/pgwchannel/services/pgw'; public function isConfigured(): bool { return $this->cfg('mellat_terminal_id', $this->terminalId) !== '' && $this->cfg('mellat_username', $this->username) !== '' && $this->cfg('mellat_password', $this->password) !== ''; } private function cfg(string $key, ?string $envFallback): string { return (string) ($this->configRepo->get($key) ?: $envFallback ?? ''); } public function initiate(...) { $response = $this->httpClient->request('POST', self::SERVICE_URL, [...]); ... $redirectUrl = self::PAYMENT_URL . '?RefId=' . $refId; } public function verify(...) { $response = $this->httpClient->request('POST', self::SERVICE_URL, [...]); } // buildRequestPayload / buildVerifySettlePayload از cfg('mellat_terminal_id'...) می‌خوانند } ``` ### `GatewayFactory.php` — `resolve()` در حالت test_mode همیشه Mock می‌دهد ```php public function resolve(string $name): ?PaymentGatewayInterface { if ($this->isTestMode()) { return $this->mock; // ← مانع اتصال واقعی به sandbox } if (!$this->isEnabled($name)) { return null; } return $this->gateways[$name] ?? null; } public function activeGateways(): array { if ($this->isTestMode()) { return [['name' => 'mellat', 'label' => 'بانک ملت (آزمایشی)']]; } $out = []; foreach ($this->gateways as $name => $gateway) { if ($gateway->isConfigured() && $this->isEnabled($name)) { $out[] = ['name' => $name, 'label' => self::LABELS[$name] ?? $name]; } } return $out; } ``` ### `SecurityHeadersSubscriber.php` (خط ۴۴) ```php . "form-action https://*.shaparak.ir; base-uri 'none'" ``` ## وظایف ### ۱. `MellatGateway` — سوییچ sandbox `PAYMENT_URL` و `SERVICE_URL` را از `const` به **متد** تبدیل کن که بر اساس فلگ `mellat_sandbox` مقدار برمی‌گرداند. credentials هم وقتی sandbox روشن است از ثابت‌های sandbox به‌عنوان fallback استفاده کنند (اولویت همچنان با config key اگر ست شده باشد). ```php class MellatGateway implements PaymentGatewayInterface { // prod (پیش‌فرض) private const PAYMENT_URL = 'https://bpm.shaparak.ir/pgwchannel/startpay.mellat'; private const SERVICE_URL = 'https://bpm.shaparak.ir/pgwchannel/services/pgw'; // sandbox banktest.ir (موقت — همان کانال SOAP pgwchannel) private const SANDBOX_PAYMENT_URL = 'https://sandbox.banktest.ir/mellat/bpm.shaparak.ir/pgwchannel/startpay.mellat'; private const SANDBOX_SERVICE_URL = 'https://sandbox.banktest.ir/mellat/bpm.shaparak.ir/pgwchannel/services/pgw'; private const SANDBOX_TERMINAL_ID = '134759344'; private const SANDBOX_USERNAME = 'user134759344'; private const SANDBOX_PASSWORD = '17384843'; private function sandbox(): bool { return $this->configRepo->get('mellat_sandbox') === '1'; } private function serviceUrl(): string { return $this->sandbox() ? self::SANDBOX_SERVICE_URL : self::SERVICE_URL; } private function paymentUrl(): string { return $this->sandbox() ? self::SANDBOX_PAYMENT_URL : self::PAYMENT_URL; } ``` سپس: - در `initiate()` و `verify()` به‌جای `self::SERVICE_URL` از `$this->serviceUrl()` استفاده کن. - در `initiate()` به‌جای `self::PAYMENT_URL` از `$this->paymentUrl()` استفاده کن. - در `cfg()` وقتی sandbox روشن است، fallback را به مقدار sandbox بده. تمیزترین راه: یک helper که envFallback را در حالت sandbox override کند: ```php private function cfg(string $key, ?string $envFallback): string { if ($this->sandbox()) { $envFallback = match ($key) { 'mellat_terminal_id' => self::SANDBOX_TERMINAL_ID, 'mellat_username' => self::SANDBOX_USERNAME, 'mellat_password' => self::SANDBOX_PASSWORD, default => $envFallback, }; // در sandbox، config مقدار prod را override نکند: return $envFallback ?? ''; } return (string) ($this->configRepo->get($key) ?: $envFallback ?? ''); } ``` > توجه: در حالت sandbox عمداً `configRepo` را نادیده می‌گیریم تا credentials واقعیِ prod (اگر در تنظیمات ست شده باشند) روی sandbox نشتی نکنند. `isConfigured()` بدون تغییر می‌ماند و چون `cfg()` مقادیر sandbox را می‌دهد، خودبه‌خود `true` می‌شود. ### ۲. `GatewayFactory` — استفاده از mellat در sandbox بدون Mock sandbox یعنی اتصال واقعی به banktest، پس **نباید** Mock برگردد. یک متد `isMellatSandbox()` اضافه کن و در `resolve()` قبل از چک `isTestMode()` لحاظ کن. همچنین برچسب sandbox در `activeGateways()`. ```php public function isMellatSandbox(): bool { return $this->configRepo->get('mellat_sandbox') === '1'; } public function resolve(string $name): ?PaymentGatewayInterface { // sandbox ملت: اتصال واقعی به banktest، نه Mock. if ($name === 'mellat' && $this->isMellatSandbox()) { return $this->gateways['mellat'] ?? null; } if ($this->isTestMode()) { return $this->mock; } if (!$this->isEnabled($name)) { return null; } return $this->gateways[$name] ?? null; } public function activeGateways(): array { if ($this->isMellatSandbox()) { return [['name' => 'mellat', 'label' => 'بانک ملت (Sandbox)']]; } if ($this->isTestMode()) { return [['name' => 'mellat', 'label' => 'بانک ملت (آزمایشی)']]; } // ... بدون تغییر } ``` > نکته: برای تست sandbox، `payment_test_mode` باید `0` باشد و `mellat_sandbox` برابر `1`. اگر هر دو `1` باشند، sandbox اولویت دارد (طبق ترتیب بالا). ### ۳. CSP — اجازهٔ فرم به sandbox.banktest.ir در `SecurityHeadersSubscriber.php` خط `form-action` را گسترش بده: ```php . "form-action https://*.shaparak.ir https://sandbox.banktest.ir; base-uri 'none'" ``` ### ۴. `SiteConfigController` — کلید قابل‌تنظیم `mellat_sandbox` را به `ALLOWED_KEYS` اضافه کن تا از `/admin/settings` قابل toggle باشد (یا مستقیم در DB ست شود). مقدار: `'1'` روشن / `'0'` یا نبود = خاموش. ### ۵. مستندسازی — `docs/api/payment.md` یک بخش «حالت Sandbox ملت (موقت)» اضافه کن: - فلگ `mellat_sandbox=1` (و `payment_test_mode=0`). - base-URLها و credentials sandbox. - توضیح اینکه از کانال SOAP `pgwchannel` استفاده می‌شود و همان verify+settle + چک ضد-دستکاری برقرار است. - هشدار: **موقت**، فقط روی branch جدا، بعد از تست حذف شود. ## تست (e2e روی ddev) ```bash # روشن‌کردن sandbox، خاموش‌کردن test_mode ddev exec mysql -e "INSERT INTO site_config (config_key,config_value,updated_at) VALUES ('mellat_sandbox','1',UNIX_TIMESTAMP()) ON DUPLICATE KEY UPDATE config_value='1',updated_at=UNIX_TIMESTAMP(); UPDATE site_config SET config_value='0' WHERE config_key='payment_test_mode';" db ddev exec php -l src/Payment/Gateway/MellatGateway.php ddev exec php -l src/Payment/Gateway/GatewayFactory.php ddev exec php bin/console cache:clear # ساخت یک Payment pending با gateway=mellat و امتحان initiate (باید RefId واقعی از sandbox بگیرد یا کد خطای معنی‌دار ملت) # سپس pay → redirect به فرم sandbox → callback → verify+settle ``` معیار موفقیت: `initiate` از banktest یک `0,` برمی‌گرداند (نه HTTP 500، نه exception اتصال)، فرم auto-submit به `sandbox.banktest.ir/.../startpay.mellat` باز می‌شود، بعد از پرداخت آزمایشی callback به بک‌اند می‌آید، `verify` با ResCode `0/43/45` موفق می‌شود و `payment.status=success` + `reference_id` ثبت می‌شود. ## نکات مهم - **برگشت‌پذیری:** کل رفتار sandbox پشت فلگ `mellat_sandbox` است؛ خاموش‌کردنش = رفتار prod. هیچ URL prod حذف نشود. - **جدا از test_mode:** sandbox اتصال واقعی است؛ نباید `MockGateway` برگردد. - **credentials در حالت sandbox از config خوانده نشوند** تا مقادیر prod نشتی نکنند (عمدی). - چک ضد-دستکاری در `PaymentManager` (RefId==gateway_token، SaleOrderId==payment.id) و numeric orderId (`payment.id`) بدون تغییر باقی می‌مانند و با sandbox هم کار می‌کنند. - `SERVICE_URL` sandbox **بدون** `?wsdl` باشد (POST به `?wsdl` خطای 500 می‌دهد). - چون موقت است، credentials به‌صورت const در کلاس قرار می‌گیرند (نه `.env`)؛ این عمدی و برای سادگی حذف بعدی است.