feat(payment): enhance Mellat gateway with sandbox support and WSDL configuration

This commit is contained in:
hamed
2026-07-02 20:15:41 +03:30
parent 1b171a82f4
commit 27b2d66afa
5 changed files with 330 additions and 149 deletions
@@ -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',
+84 -149
View File
@@ -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');