229 lines
11 KiB
Markdown
229 lines
11 KiB
Markdown
# بردن درگاه ملت روی محیط واقعی (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=<terminal واقعی>
|
||
MELLAT_USERNAME=<username واقعی>
|
||
MELLAT_PASSWORD=<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`.
|