# بردن درگاه ملت روی محیط واقعی (production) با SOAP native ## پروژه `clinicpro` (backend / Payment) ## زمینه درگاه ملت الان دو مسیر دارد: - **sandbox** (`mellat_sandbox=1`): REST روی `banktest.ir` (کار می‌کند، برای تست). - **prod** (پیش‌فرض): SOAP روی `bpm.shaparak.ir` که فعلاً با **POST خام XML** توسط `Symfony HttpClient` به `pgwchannel/services/pgw` زده می‌شود. طبق آموزش رسمی بانک (`mellat-payment-gateway-symfony.md`, Mellat PGW Tech Doc v1.38)، روش توصیه‌شده برای prod استفاده از **`SoapClient` بومی PHP روی WSDL** است (نه POST خام). اکستنشن `soap` روی سرور فعال است (تأیید شد). مسیر prod باید به `SoapClient` تبدیل شود تا با درگاه واقعی پایدار کار کند؛ مسیر sandbox (REST) دست‌نخورده می‌ماند. **هدف:** مسیر prod درگاه ملت با `SoapClient` واقعی کار کند و آمادهٔ رفتن روی دامنهٔ عملیاتی باشد. sandbox برای تست باقی بماند. ## فایل‌های مرتبط | فایل | نقش | |------|-----| | `clinicpro/src/Payment/Gateway/MellatGateway.php` | جایگزینی مسیر prod (POST خام XML) با `SoapClient`؛ حفظ مسیر sandbox REST | | `clinicpro/config/services.yaml` | (در صورت نیاز) binding `mellat_wsdl_url` | | `clinicpro/.env` | مقادیر prod (WSDL/redirect) + credentials واقعی | | `clinicpro/docs/api/payment.md` | مستندسازی مسیر prod SOAP | ## وضعیت فعلی ### `MellatGateway.php` — ثابت‌ها و مسیر prod فعلی (POST خام) ```php private const PAYMENT_URL = 'https://bpm.shaparak.ir/pgwchannel/startpay.mellat'; private const SERVICE_URL = 'https://bpm.shaparak.ir/pgwchannel/services/pgw'; // prod SOAP (POST خام) // sandbox (REST) — بدون تغییر باقی می‌ماند private const SANDBOX_REST_BASE = 'https://sandbox.banktest.ir/mellat/bpm.shaparak.ir/ipg2/rest'; private const SANDBOX_PAYMENT_URL = 'https://sandbox.banktest.ir/mellat/bpm.shaparak.ir/pgwchannel/startpay.mellat'; private const SANDBOX_TERMINAL_ID = '134759344'; // ... public function initiate(...) { if ($this->sandbox()) { /* REST bpPayRequest */ } else { $response = $this->httpClient->request('POST', self::SERVICE_URL, [ 'body' => $this->buildRequestPayload(...), // ← XML خام 'headers' => ['Content-Type' => 'text/xml; charset=utf-8', 'SOAPAction' => '""'], ]); $resCode = $this->parseResCode($response->getContent()); // ← regex روی XML $refId = $this->parseRefId($response->getContent()); } // ... } public function verify(array $callbackData) { if ($this->sandbox()) { /* REST verify + settle جدا */ } else { $response = $this->httpClient->request('POST', self::SERVICE_URL, [ 'body' => $this->buildVerifySettlePayload($saleOrderId, $saleReferenceId), // ← bpVerifySettleRequest خام // ... ]); $verifyCode = $this->parseResCode($response->getContent()); } } // refund()/reverse() prod هم همین الگوی POST خام را دارند (buildRefundPayload/buildReversalPayload) // helperهای فقط-prod: buildRequestPayload, buildVerifySettlePayload, buildRefundPayload, // buildReversalPayload, parseResCode, parseRefId ``` اعتبارنامه از `cfg()` خوانده می‌شود: ```php private function cfg(string $key, ?string $envFallback): string { if ($this->sandbox()) { /* const های sandbox */ } return (string) ($this->configRepo->get($key) ?: $envFallback ?? ''); } // prod: mellat_terminal_id / mellat_username / mellat_password از site_config یا env (MELLAT_*) ``` ## وظایف ### ۱. افزودن WSDL prod + سازندهٔ تنبل SoapClient یک ثابت WSDL و یک متد که `SoapClient` را lazy می‌سازد (فقط prod، فقط وقتی لازم شد؛ تا sandbox/mock را نشکند و هزینهٔ ساخت WSDL بی‌مورد نپردازد). ```php private const PROD_WSDL = 'https://bpm.shaparak.ir/pgwchannel/services/pgw?wsdl'; private ?\SoapClient $soap = null; /** WSDL قابل override با کلید تنظیم mellat_wsdl_url (برای WSDL تستِ pgw.dev اگر لازم شد). */ private function wsdlUrl(): string { return (string) ($this->configRepo->get('mellat_wsdl_url') ?: self::PROD_WSDL); } private function soap(): \SoapClient { if ($this->soap === null) { $this->soap = new \SoapClient($this->wsdlUrl(), [ 'trace' => true, 'exceptions' => true, 'encoding' => 'UTF-8', 'connection_timeout' => 10, ]); } return $this->soap; } /** پارامترهای مشترک احراز هویت prod. */ private function soapAuth(): array { return [ 'terminalId' => (int) $this->cfg('mellat_terminal_id', $this->terminalId), 'userName' => $this->cfg('mellat_username', $this->username), 'userPassword' => $this->cfg('mellat_password', $this->password), ]; } ``` ### ۲. `initiate()` — مسیر prod با SoapClient مسیر sandbox بدون تغییر. مسیر else را جایگزین کن: ```php } else { $r = $this->soap()->bpPayRequest($this->soapAuth() + [ 'orderId' => (int) $orderId, 'amount' => $amountRials, 'localDate' => $this->date(), 'localTime' => $this->time(), 'additionalData' => '', 'callBackUrl' => $callbackUrl, 'payerId' => 0, ]); // پاسخ "resCode,refId" $parts = array_map('trim', explode(',', (string) ($r->return ?? ''), 2)); $resCode = $parts[0] ?? '-1'; $refId = $parts[1] ?? ''; } ``` `SoapFault` باید مثل بقیه در `catch (\Throwable $e)` موجود گرفته شود (هست). ### ۳. `verify()` — prod با verify + settle جدا (طبق آموزش) طبق آموزش، prod هم مثل sandbox **verify سپس settle جدا** انجام شود (به‌جای `bpVerifySettleRequest` ترکیبی). کدها: verify `0`/`43` موفق، settle `0`/`45` موفق. ```php } else { $auth = $this->soapAuth() + [ 'orderId' => (int) $saleOrderId, 'saleOrderId' => (int) $saleOrderId, 'saleReferenceId' => (int) $saleReferenceId, ]; $vc = (string) ($this->soap()->bpVerifyRequest($auth)->return ?? '-1'); if (!in_array($vc, ['0', '43'], true)) { return new PaymentVerifyResult(false, errorMessage: $this->mellatMessage($vc)); } $sc = (string) ($this->soap()->bpSettleRequest($auth)->return ?? '-1'); if (!in_array($sc, ['0', '45'], true)) { return new PaymentVerifyResult(false, errorMessage: $this->mellatMessage($sc)); } return new PaymentVerifyResult(true, referenceId: $saleReferenceId); } ``` > نکته: بعد از این تغییر، پیام خطای prod هم از `mellatMessage()` استفاده کند (نه رشتهٔ خام «Verify failed»). چک `ResCode === '17'` (انصراف) و ناقص‌بودن `saleOrderId/saleReferenceId` که قبل از try هست، حفظ شود. ### ۴. `refund()` / `reverse()` — prod با SoapClient ```php // refund prod: $r = $this->soap()->bpRefundRequest($this->soapAuth() + [ 'orderId' => $this->uniqueOrderId(), 'saleOrderId' => (int) $saleOrderId, 'saleReferenceId' => (int) $saleReferenceId, 'refundAmount' => $refundAmountRials, ]); $parts = array_map('trim', explode(',', (string) ($r->return ?? ''), 2)); $code = $parts[0] ?? '-1'; // code !== '0' → PaymentRefundResult(false, mellatMessage($code)); else refundRefId = $parts[1] // reverse prod: $code = (string) ($this->soap()->bpReversalRequest($this->soapAuth() + [ 'orderId' => $this->uniqueOrderId(), 'saleOrderId' => (int) $saleOrderId, 'saleReferenceId' => (int) $saleReferenceId, ])->return ?? '-1'); // in_array($code, ['0','48']) → success ``` ### ۵. حذف helperهای مردهٔ prod بعد از سوییچ به SoapClient، این‌ها دیگر استفاده نمی‌شوند و باید حذف شوند (اگر جای دیگری استفاده نشده‌اند — بررسی کن): `buildRequestPayload`, `buildVerifySettlePayload`, `buildRefundPayload`, `buildReversalPayload`, `parseResCode`, `parseRefId`, و ثابت `SERVICE_URL` (POST خام). `restParts`/`restCall`/`restHeaders` و ثابت‌های sandbox باقی می‌مانند (sandbox REST همچنان از آن‌ها استفاده می‌کند). ### ۶. تنظیمات محیط prod - `.env` (سرور عملیاتی): ```dotenv APP_BASE_URL=https://<دامنهٔ-ثبت‌شده-نزد-ملت> # مثلا https://clinic-pro.ir # اعتبارنامهٔ واقعی (یا از /admin/settings ست شود): MELLAT_TERMINAL_ID= MELLAT_USERNAME= MELLAT_PASSWORD= # WSDL prod پیش‌فرض است؛ فقط اگر WSDL تستِ pgw.dev خواستی: # کلید site_config: mellat_wsdl_url = https://pgw.dev.bpmellat.ir/pgwchannel/services/pgw?wsdl ``` - در `/admin/settings`: `mellat_sandbox=0` (خاموش) و `payment_test_mode=0`؛ `mellat_enabled=1` و در صورت نبود env، `mellat_terminal_id/username/password` را همان‌جا ست کن. ## نکات مهم (چک‌لیست عملیاتی prod) - **IP سرور** باید توسط «شرکت به‌پرداخت ملت» whitelist شود، وگرنه کد `421` (IP نامعتبر). - **دامنهٔ callBackUrl** = `APP_BASE_URL` باید **دقیقاً دامنهٔ ثبت‌شده نزد ملت** باشد (نه IP)، وگرنه کد `62`. `callbackUrl()` در `PaymentManager` از `APP_BASE_URL` می‌سازد. - **ext-soap** روی سرور prod فعال باشد (`php -m | grep soap`) — روی ddev هست. - پورت‌های `443`/`80` خروجی سرور به شبکهٔ شاپرک باز باشد. - `orderId` مرحلهٔ Pay = `payment.id` عددی (هست)؛ refund/reverse از `uniqueOrderId()` یکتا استفاده می‌کنند (هست). - چک ضد-دستکاری callback (`RefId==gateway_token`, `SaleOrderId==payment.id`) در `PaymentManager::processCallback` برای prod هم فعال است — دست نخورد. - **Auto-reversal**: اگر verify ظرف ۲۰ دقیقه بعد از پرداخت موفق ارسال نشود، بانک خودکار برگشت می‌زند؛ چون verify در callback بلافاصله انجام می‌شود مشکلی نیست. - SoapClient روی خطای شبکه/WSDL `SoapFault` می‌اندازد که در `catch (\Throwable)` موجود گرفته و به پیام کاربر تبدیل می‌شود. - بعد از تغییر: `ddev exec php -l`، `cache:clear`، و یک تراکنش واقعی با **مبلغ کم** تست شود (prod را نمی‌توان با mock تست کرد؛ فقط syntax + بارگذاری WSDL قابل بررسی خودکار است). - `docs/api/payment.md` را به‌روزرسانی کن (prod = SoapClient؛ verify+settle جدا). ## آنچه نباید تغییر کند - مسیر **sandbox REST** (`initiate`/`verify`/`refund`/`reverse` وقتی `sandbox()` true است). - منطق `PaymentManager` (transaction/lock/tamper/reverse-post-action). - `SepGateway`/`MockGateway`.