feat(payment): enhance Mellat gateway with sandbox support and WSDL configuration
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
# بردن درگاه ملت روی محیط واقعی (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`.
|
||||
@@ -37,6 +37,8 @@ const schema = z.object({
|
||||
// payment gateways
|
||||
payment_test_mode: z.string(),
|
||||
mellat_enabled: z.string(),
|
||||
mellat_sandbox: z.string(),
|
||||
mellat_wsdl_url: z.string(),
|
||||
mellat_terminal_id: z.string(),
|
||||
mellat_username: z.string(),
|
||||
mellat_password: z.string(),
|
||||
@@ -65,6 +67,8 @@ const toForm = (s: Partial<Settings>): FormValues => ({
|
||||
appointment_fee_rials: s.appointment_fee_rials ?? '150000',
|
||||
payment_test_mode: s.payment_test_mode ?? '0',
|
||||
mellat_enabled: s.mellat_enabled ?? '1',
|
||||
mellat_sandbox: s.mellat_sandbox ?? '0',
|
||||
mellat_wsdl_url: s.mellat_wsdl_url ?? '',
|
||||
mellat_terminal_id: s.mellat_terminal_id ?? '',
|
||||
mellat_username: s.mellat_username ?? '',
|
||||
mellat_password: s.mellat_password ?? '',
|
||||
@@ -202,6 +206,7 @@ export default function SettingsPage() {
|
||||
|
||||
const paymentTestMode = watch('payment_test_mode') === '1';
|
||||
const mellatEnabled = watch('mellat_enabled') === '1';
|
||||
const mellatSandbox = watch('mellat_sandbox') === '1';
|
||||
const sepEnabled = watch('sep_enabled') === '1';
|
||||
const apptCommissionEnabled = watch('appointment_commission_enabled') === '1';
|
||||
const upgradeCommissionEnabled = watch('upgrade_commission_enabled') === '1';
|
||||
@@ -423,6 +428,15 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="settings-grid" style={{ gridTemplateColumns: '1fr auto', alignItems: 'end', marginTop: 12 }}>
|
||||
<Field label="آدرس WSDL (اختیاری — خالی = عملیاتی)">
|
||||
<input {...register('mellat_wsdl_url')} className="input" dir="ltr" placeholder="https://bpm.shaparak.ir/pgwchannel/services/pgw?wsdl" />
|
||||
</Field>
|
||||
<div className="gw-state" style={{ color: mellatSandbox ? 'var(--warning)' : 'var(--text-3)' }}>
|
||||
{mellatSandbox ? 'Sandbox (آزمایشگاه)' : 'واقعی'}
|
||||
<Toggle checked={mellatSandbox} onChange={() => toggle('mellat_sandbox', mellatSandbox)} label="حالت Sandbox ملت" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* سپ */}
|
||||
|
||||
@@ -57,6 +57,9 @@
|
||||
**افزودن درگاه جدید (Open/Closed):** یک کلاس جدید implements `PaymentGatewayInterface` بساز، در `GatewayFactory::$gateways` + `LABELS` ثبت کن. `PaymentController`/`PaymentManager` تغییر نمیکنند.
|
||||
|
||||
**درگاه ملت (BPM) — نکات پیادهسازی طبق مستند رسمی ۱.۳۸:**
|
||||
- **prod = SOAP با `SoapClient` بومی** روی WSDL عملیاتی `https://bpm.shaparak.ir/pgwchannel/services/pgw?wsdl` (متدها: `bpPayRequest`، `bpVerifyRequest`+`bpSettleRequest` جدا، `bpRefundRequest`، `bpReversalRequest`). WSDL با کلید `mellat_wsdl_url` قابل override است (مثلاً WSDL تستِ pgw.dev). نیازمند اکستنشن `soap` روی سرور. **sandbox = REST** (banktest.ir) بدون تغییر.
|
||||
- **چکلیست عملیاتی prod:** (۱) IP سرور نزد «بهپرداخت ملت» whitelist شود (وگرنه کد `421`). (۲) `APP_BASE_URL` = دامنهٔ ثبتشدهٔ پذیرنده (نه IP؛ وگرنه کد `62`). (۳) اعتبارنامهٔ واقعی در env (`MELLAT_*`) یا `/admin/settings`. (۴) `mellat_sandbox=0` و `payment_test_mode=0`. (۵) پورتهای ۴۴۳/۸۰ خروجی باز.
|
||||
- کدهای پاسخ با `mellatMessage()` به پیام فارسی نگاشت میشوند.
|
||||
- `orderId` ملت از نوع **long (عددی)** است؛ به همین دلیل `PaymentManager` هنگام init، **`payment.id` عددی** را بهعنوان orderId درگاه میفرستد (نه رشتهٔ `ORD-…`). جستجوی پرداخت در callback از طریق query `order_id` (رشتهٔ `ORD-…`) انجام میشود.
|
||||
- **verify+settle** با یک فراخوانی `bpVerifySettleRequest` انجام میشود و به `saleOrderId` (= همان `payment.id` مرحلهٔ Pay) و `saleReferenceId` (که بانک در callback POST میفرستد) نیاز دارد — **نه** `RefId`. کدهای `0`/`43`/`45` (موفق/قبلاً verify/قبلاً settle) موفق تلقی میشوند. `reference_id` ذخیرهشده = `SaleReferenceId`.
|
||||
- **چک ضد-دستکاری (اجباری مستند):** در callback، `RefId` بازگشتی باید با `gateway_token` ذخیرهشده و `SaleOrderId` با `payment.id` برابر باشد؛ در غیر اینصورت تراکنش `failed` میشود (این چک برای درگاههایی که این فیلدها را برنمیگردانند، مثل سپ، رد میشود).
|
||||
|
||||
@@ -35,6 +35,7 @@ class SiteConfigController extends BaseController
|
||||
'payment_allowed_frontend_hosts',
|
||||
'mellat_enabled',
|
||||
'mellat_sandbox',
|
||||
'mellat_wsdl_url',
|
||||
'mellat_terminal_id',
|
||||
'mellat_username',
|
||||
'mellat_password',
|
||||
|
||||
@@ -9,8 +9,8 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
class MellatGateway implements PaymentGatewayInterface
|
||||
{
|
||||
private const PAYMENT_URL = 'https://bpm.shaparak.ir/pgwchannel/startpay.mellat';
|
||||
// endpoint سرویس SOAP (بدون ?wsdl؛ ?wsdl فقط توصیفِ سرویس است و POST به آن 500 میدهد).
|
||||
private const SERVICE_URL = 'https://bpm.shaparak.ir/pgwchannel/services/pgw';
|
||||
// WSDL عملیاتی؛ prod با SoapClient بومی روی این WSDL کار میکند (طبق آموزش رسمی v1.38).
|
||||
private const PROD_WSDL = 'https://bpm.shaparak.ir/pgwchannel/services/pgw?wsdl';
|
||||
|
||||
// sandbox banktest.ir (موقت). پشت فلگ mellat_sandbox.
|
||||
// نکته: SOAP sandbox (pgwchannel) روی banktest 502 میدهد؛ فقط REST (ipg2) سالم است،
|
||||
@@ -22,6 +22,8 @@ class MellatGateway implements PaymentGatewayInterface
|
||||
private const SANDBOX_USERNAME = 'user134759344';
|
||||
private const SANDBOX_PASSWORD = '17384843';
|
||||
|
||||
private ?\SoapClient $soap = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
private readonly SiteConfigRepository $configRepo,
|
||||
@@ -53,6 +55,40 @@ class MellatGateway implements PaymentGatewayInterface
|
||||
return $this->sandbox() ? self::SANDBOX_PAYMENT_URL : self::PAYMENT_URL;
|
||||
}
|
||||
|
||||
// ── prod SOAP ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** WSDL قابل override با کلید تنظیم mellat_wsdl_url (برای WSDL تستِ pgw.dev اگر لازم شد). */
|
||||
private function wsdlUrl(): string
|
||||
{
|
||||
return (string) ($this->configRepo->get('mellat_wsdl_url') ?: self::PROD_WSDL);
|
||||
}
|
||||
|
||||
/** SoapClient تنبل — فقط prod و فقط وقتی لازم شد ساخته میشود. */
|
||||
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),
|
||||
];
|
||||
}
|
||||
|
||||
// ── sandbox REST ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** هدر Basic Auth برای REST sandbox (base64 از userName:userPassword). */
|
||||
private function restHeaders(): array
|
||||
{
|
||||
@@ -116,21 +152,23 @@ class MellatGateway implements PaymentGatewayInterface
|
||||
])->getContent()
|
||||
) + ['-1', ''];
|
||||
} else {
|
||||
$response = $this->httpClient->request(
|
||||
'POST',
|
||||
self::SERVICE_URL,
|
||||
[
|
||||
'body' => $this->buildRequestPayload($amountRials, $orderId, $callbackUrl),
|
||||
'headers' => ['Content-Type' => 'text/xml; charset=utf-8', 'SOAPAction' => '""'],
|
||||
'timeout' => 10,
|
||||
]
|
||||
);
|
||||
$resCode = $this->parseResCode($response->getContent());
|
||||
$refId = $this->parseRefId($response->getContent());
|
||||
$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] ?? '';
|
||||
}
|
||||
|
||||
if ($resCode !== '0') {
|
||||
return new PaymentInitResult(false, errorMessage: "Mellat error: $resCode");
|
||||
return new PaymentInitResult(false, errorMessage: $this->mellatMessage($resCode));
|
||||
}
|
||||
|
||||
$redirectUrl = $this->paymentUrl() . '?RefId=' . $refId;
|
||||
@@ -161,7 +199,7 @@ class MellatGateway implements PaymentGatewayInterface
|
||||
}
|
||||
|
||||
if ($resCode !== '0') {
|
||||
return new PaymentVerifyResult(false, errorMessage: "Payment failed: $resCode");
|
||||
return new PaymentVerifyResult(false, errorMessage: $this->mellatMessage($resCode));
|
||||
}
|
||||
|
||||
// برای تأیید و واریز، ملت به saleOrderId (همان orderId مرحلهٔ Pay) و
|
||||
@@ -171,9 +209,8 @@ class MellatGateway implements PaymentGatewayInterface
|
||||
}
|
||||
|
||||
try {
|
||||
// هم sandbox و هم prod: verify سپس settle جدا. 0=موفق، 43=قبلاً verify، 45=قبلاً settle.
|
||||
if ($this->sandbox()) {
|
||||
// sandbox banktest متد ترکیبی bpVerifySettleRequest را پشتیبانی نمیکند (کد 44)؛
|
||||
// پس verify و settle جدا صدا زده میشوند. 0=موفق، 43=قبلاً verify، 45=قبلاً settle.
|
||||
$payload = [
|
||||
'terminalId' => (int) self::SANDBOX_TERMINAL_ID,
|
||||
'userName' => self::SANDBOX_USERNAME,
|
||||
@@ -184,30 +221,28 @@ class MellatGateway implements PaymentGatewayInterface
|
||||
];
|
||||
$vc = $this->restCall('/bpVerifyRequest', $payload);
|
||||
if (!in_array($vc, ['0', '43'], true)) {
|
||||
return new PaymentVerifyResult(false, errorMessage: "Verify failed: $vc");
|
||||
return new PaymentVerifyResult(false, errorMessage: $this->mellatMessage($vc));
|
||||
}
|
||||
$sc = $this->restCall('/bpSettleRequest', $payload);
|
||||
if (!in_array($sc, ['0', '45'], true)) {
|
||||
return new PaymentVerifyResult(false, errorMessage: "Settle failed: $sc");
|
||||
return new PaymentVerifyResult(false, errorMessage: $this->mellatMessage($sc));
|
||||
}
|
||||
return new PaymentVerifyResult(true, referenceId: $saleReferenceId);
|
||||
}
|
||||
|
||||
$response = $this->httpClient->request(
|
||||
'POST',
|
||||
self::SERVICE_URL,
|
||||
[
|
||||
'body' => $this->buildVerifySettlePayload($saleOrderId, $saleReferenceId),
|
||||
'headers' => ['Content-Type' => 'text/xml; charset=utf-8', 'SOAPAction' => '""'],
|
||||
'timeout' => 10,
|
||||
]
|
||||
);
|
||||
$verifyCode = $this->parseResCode($response->getContent());
|
||||
// 0 = موفق، 43 = پیشتر verify شده، 45 = پیشتر settle شده (هر دو idempotent = موفق).
|
||||
if (!in_array($verifyCode, ['0', '43', '45'], true)) {
|
||||
return new PaymentVerifyResult(false, errorMessage: "Verify failed: $verifyCode");
|
||||
$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);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error(sprintf('Payment verify failed (mellat): %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'saleReferenceId' => $saleReferenceId]);
|
||||
@@ -215,55 +250,6 @@ class MellatGateway implements PaymentGatewayInterface
|
||||
}
|
||||
}
|
||||
|
||||
private function buildRequestPayload(int $amount, string $orderId, string $callbackUrl): string
|
||||
{
|
||||
$terminalId = $this->cfg('mellat_terminal_id', $this->terminalId);
|
||||
$username = $this->cfg('mellat_username', $this->username);
|
||||
$password = $this->cfg('mellat_password', $this->password);
|
||||
|
||||
return <<<XML
|
||||
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:int="http://interfaces.core.sw.bsl.com/westernmellat">
|
||||
<soapenv:Body>
|
||||
<int:bpPayRequest>
|
||||
<terminalId>{$terminalId}</terminalId>
|
||||
<userName>{$username}</userName>
|
||||
<userPassword>{$password}</userPassword>
|
||||
<orderId>{$orderId}</orderId>
|
||||
<amount>{$amount}</amount>
|
||||
<localDate>{$this->date()}</localDate>
|
||||
<localTime>{$this->time()}</localTime>
|
||||
<additionalData></additionalData>
|
||||
<callBackUrl>{$callbackUrl}</callBackUrl>
|
||||
<payerId>0</payerId>
|
||||
</int:bpPayRequest>
|
||||
</soapenv:Body>
|
||||
</soapenv:Envelope>
|
||||
XML;
|
||||
}
|
||||
|
||||
/** تأیید و واریز یکجا (bpVerifySettleRequest). orderId میتواند برابر saleOrderId باشد. */
|
||||
private function buildVerifySettlePayload(string $saleOrderId, string $saleReferenceId): string
|
||||
{
|
||||
$terminalId = $this->cfg('mellat_terminal_id', $this->terminalId);
|
||||
$username = $this->cfg('mellat_username', $this->username);
|
||||
$password = $this->cfg('mellat_password', $this->password);
|
||||
|
||||
return <<<XML
|
||||
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:int="http://interfaces.core.sw.bsl.com/westernmellat">
|
||||
<soapenv:Body>
|
||||
<int:bpVerifySettleRequest>
|
||||
<terminalId>{$terminalId}</terminalId>
|
||||
<userName>{$username}</userName>
|
||||
<userPassword>{$password}</userPassword>
|
||||
<orderId>{$saleOrderId}</orderId>
|
||||
<saleOrderId>{$saleOrderId}</saleOrderId>
|
||||
<saleReferenceId>{$saleReferenceId}</saleReferenceId>
|
||||
</int:bpVerifySettleRequest>
|
||||
</soapenv:Body>
|
||||
</soapenv:Envelope>
|
||||
XML;
|
||||
}
|
||||
|
||||
public function refund(string $saleOrderId, string $saleReferenceId, int $refundAmountRials): PaymentRefundResult
|
||||
{
|
||||
try {
|
||||
@@ -276,12 +262,13 @@ XML;
|
||||
])->getContent()
|
||||
);
|
||||
} else {
|
||||
$xml = $this->httpClient->request('POST', self::SERVICE_URL, [
|
||||
'body' => $this->buildRefundPayload($saleOrderId, $saleReferenceId, $refundAmountRials),
|
||||
'headers' => ['Content-Type' => 'text/xml; charset=utf-8', 'SOAPAction' => '""'],
|
||||
'timeout' => 10,
|
||||
])->getContent();
|
||||
$parts = [$this->parseResCode($xml), $this->parseRefId($xml)];
|
||||
$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';
|
||||
if ($code !== '0') {
|
||||
@@ -300,12 +287,11 @@ XML;
|
||||
if ($this->sandbox()) {
|
||||
$code = $this->restCall('/bpReversalRequest', $this->reversalPayload($saleOrderId, $saleReferenceId));
|
||||
} else {
|
||||
$xml = $this->httpClient->request('POST', self::SERVICE_URL, [
|
||||
'body' => $this->buildReversalPayload($saleOrderId, $saleReferenceId),
|
||||
'headers' => ['Content-Type' => 'text/xml; charset=utf-8', 'SOAPAction' => '""'],
|
||||
'timeout' => 10,
|
||||
])->getContent();
|
||||
$code = $this->parseResCode($xml);
|
||||
$code = (string) ($this->soap()->bpReversalRequest($this->soapAuth() + [
|
||||
'orderId' => $this->uniqueOrderId(),
|
||||
'saleOrderId' => (int) $saleOrderId,
|
||||
'saleReferenceId' => (int) $saleReferenceId,
|
||||
])->return ?? '-1');
|
||||
}
|
||||
// 0 = موفق، 48 = پیشتر reverse شده (idempotent = موفق).
|
||||
if (!in_array($code, ['0', '48'], true)) {
|
||||
@@ -330,6 +316,7 @@ XML;
|
||||
'24' => 'اطلاعات کاربری پذیرنده نامعتبر است',
|
||||
'25' => 'مبلغ نامعتبر است',
|
||||
'34' => 'خطای سیستمی درگاه (در محیط تست، استرداد پشتیبانی نمیشود)',
|
||||
'41' => 'شماره درخواست تکراری است',
|
||||
'42' => 'تراکنش خرید (Sale) یافت نشد',
|
||||
'43' => 'این تراکنش پیشتر تأیید شده است',
|
||||
'44' => 'درخواست تأیید یافت نشد',
|
||||
@@ -340,6 +327,7 @@ XML;
|
||||
'51' => 'تراکنش تکراری است',
|
||||
'61' => 'خطا در واریز',
|
||||
'62' => 'مسیر بازگشت در دامنهٔ ثبتشدهٔ پذیرنده نیست',
|
||||
'421' => 'IP نامعتبر است (به بانک اعلام نشده)',
|
||||
];
|
||||
return ($map[$code] ?? 'خطای درگاه') . " (کد $code)";
|
||||
}
|
||||
@@ -350,13 +338,13 @@ XML;
|
||||
return (int) substr((string) (int) (microtime(true) * 1000), -12);
|
||||
}
|
||||
|
||||
/** بدنهٔ JSON مشترک refund (REST). */
|
||||
/** بدنهٔ JSON مشترک refund (REST sandbox). */
|
||||
private function refundPayload(string $saleOrderId, string $saleReferenceId, int $refundAmountRials): array
|
||||
{
|
||||
return $this->reversalPayload($saleOrderId, $saleReferenceId) + ['refundAmount' => $refundAmountRials];
|
||||
}
|
||||
|
||||
/** بدنهٔ JSON مشترک reverse (REST). */
|
||||
/** بدنهٔ JSON مشترک reverse (REST sandbox). */
|
||||
private function reversalPayload(string $saleOrderId, string $saleReferenceId): array
|
||||
{
|
||||
return [
|
||||
@@ -369,59 +357,6 @@ XML;
|
||||
];
|
||||
}
|
||||
|
||||
private function buildRefundPayload(string $saleOrderId, string $saleReferenceId, int $refundAmountRials): string
|
||||
{
|
||||
$p = $this->reversalPayload($saleOrderId, $saleReferenceId);
|
||||
return <<<XML
|
||||
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:int="http://interfaces.core.sw.bsl.com/westernmellat">
|
||||
<soapenv:Body>
|
||||
<int:bpRefundRequest>
|
||||
<terminalId>{$p['terminalId']}</terminalId>
|
||||
<userName>{$p['userName']}</userName>
|
||||
<userPassword>{$p['userPassword']}</userPassword>
|
||||
<orderId>{$p['orderId']}</orderId>
|
||||
<saleOrderId>{$p['saleOrderId']}</saleOrderId>
|
||||
<saleReferenceId>{$p['saleReferenceId']}</saleReferenceId>
|
||||
<refundAmount>{$refundAmountRials}</refundAmount>
|
||||
</int:bpRefundRequest>
|
||||
</soapenv:Body>
|
||||
</soapenv:Envelope>
|
||||
XML;
|
||||
}
|
||||
|
||||
private function buildReversalPayload(string $saleOrderId, string $saleReferenceId): string
|
||||
{
|
||||
$p = $this->reversalPayload($saleOrderId, $saleReferenceId);
|
||||
return <<<XML
|
||||
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:int="http://interfaces.core.sw.bsl.com/westernmellat">
|
||||
<soapenv:Body>
|
||||
<int:bpReversalRequest>
|
||||
<terminalId>{$p['terminalId']}</terminalId>
|
||||
<userName>{$p['userName']}</userName>
|
||||
<userPassword>{$p['userPassword']}</userPassword>
|
||||
<orderId>{$p['orderId']}</orderId>
|
||||
<saleOrderId>{$p['saleOrderId']}</saleOrderId>
|
||||
<saleReferenceId>{$p['saleReferenceId']}</saleReferenceId>
|
||||
</int:bpReversalRequest>
|
||||
</soapenv:Body>
|
||||
</soapenv:Envelope>
|
||||
XML;
|
||||
}
|
||||
|
||||
private function parseResCode(string $xml): string
|
||||
{
|
||||
preg_match('/<return>(.*?)<\/return>/', $xml, $m);
|
||||
$parts = explode(',', $m[1] ?? '');
|
||||
return trim($parts[0] ?? '-1');
|
||||
}
|
||||
|
||||
private function parseRefId(string $xml): string
|
||||
{
|
||||
preg_match('/<return>(.*?)<\/return>/', $xml, $m);
|
||||
$parts = explode(',', $m[1] ?? '');
|
||||
return trim($parts[1] ?? '');
|
||||
}
|
||||
|
||||
private function date(): string
|
||||
{
|
||||
return date('Ymd');
|
||||
|
||||
Reference in New Issue
Block a user