feat(payment): update Mellat gateway implementation and enhance security checks
This commit is contained in:
@@ -9,6 +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';
|
||||
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
@@ -41,10 +43,10 @@ class MellatGateway implements PaymentGatewayInterface
|
||||
try {
|
||||
$response = $this->httpClient->request(
|
||||
'POST',
|
||||
'https://bpm.shaparak.ir/pgwchannel/services/pgw?wsdl',
|
||||
self::SERVICE_URL,
|
||||
[
|
||||
'body' => $this->buildRequestPayload($amountRials, $orderId, $callbackUrl),
|
||||
'headers' => ['Content-Type' => 'text/xml; charset=utf-8'],
|
||||
'headers' => ['Content-Type' => 'text/xml; charset=utf-8', 'SOAPAction' => '""'],
|
||||
'timeout' => 10,
|
||||
]
|
||||
);
|
||||
@@ -75,8 +77,9 @@ class MellatGateway implements PaymentGatewayInterface
|
||||
|
||||
public function verify(array $callbackData): PaymentVerifyResult
|
||||
{
|
||||
$refId = $callbackData['RefId'] ?? '';
|
||||
$resCode = $callbackData['ResCode'] ?? '';
|
||||
$resCode = $callbackData['ResCode'] ?? '';
|
||||
$saleOrderId = $callbackData['SaleOrderId'] ?? '';
|
||||
$saleReferenceId = $callbackData['SaleReferenceId'] ?? '';
|
||||
|
||||
if ($resCode === '17') {
|
||||
return new PaymentVerifyResult(false, errorMessage: 'پرداخت توسط کاربر لغو شد', canceled: true);
|
||||
@@ -86,25 +89,32 @@ class MellatGateway implements PaymentGatewayInterface
|
||||
return new PaymentVerifyResult(false, errorMessage: "Payment failed: $resCode");
|
||||
}
|
||||
|
||||
// برای تأیید و واریز، ملت به saleOrderId (همان orderId مرحلهٔ Pay) و
|
||||
// saleReferenceId (که در callback برمیگردد) نیاز دارد — نه RefId.
|
||||
if ($saleOrderId === '' || $saleReferenceId === '') {
|
||||
return new PaymentVerifyResult(false, errorMessage: 'اطلاعات بازگشتی درگاه ناقص است');
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request(
|
||||
'POST',
|
||||
'https://bpm.shaparak.ir/pgwchannel/services/pgw?wsdl',
|
||||
self::SERVICE_URL,
|
||||
[
|
||||
'body' => $this->buildVerifyPayload($refId),
|
||||
'headers' => ['Content-Type' => 'text/xml; charset=utf-8'],
|
||||
'body' => $this->buildVerifySettlePayload($saleOrderId, $saleReferenceId),
|
||||
'headers' => ['Content-Type' => 'text/xml; charset=utf-8', 'SOAPAction' => '""'],
|
||||
'timeout' => 10,
|
||||
]
|
||||
);
|
||||
|
||||
$verifyCode = $this->parseResCode($response->getContent());
|
||||
if ($verifyCode !== '0') {
|
||||
// 0 = موفق، 43 = پیشتر verify شده، 45 = پیشتر settle شده (هر دو idempotent = موفق).
|
||||
if (!in_array($verifyCode, ['0', '43', '45'], true)) {
|
||||
return new PaymentVerifyResult(false, errorMessage: "Verify failed: $verifyCode");
|
||||
}
|
||||
|
||||
return new PaymentVerifyResult(true, referenceId: $refId);
|
||||
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, 'refId' => $refId]);
|
||||
$this->logger->error(sprintf('Payment verify failed (mellat): %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'saleReferenceId' => $saleReferenceId]);
|
||||
return new PaymentVerifyResult(false, errorMessage: $e->getMessage());
|
||||
}
|
||||
}
|
||||
@@ -135,7 +145,8 @@ class MellatGateway implements PaymentGatewayInterface
|
||||
XML;
|
||||
}
|
||||
|
||||
private function buildVerifyPayload(string $refId): string
|
||||
/** تأیید و واریز یکجا (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);
|
||||
@@ -144,14 +155,14 @@ XML;
|
||||
return <<<XML
|
||||
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:int="http://interfaces.core.sw.bsl.com/westernmellat">
|
||||
<soapenv:Body>
|
||||
<int:bpVerifyRequest>
|
||||
<int:bpVerifySettleRequest>
|
||||
<terminalId>{$terminalId}</terminalId>
|
||||
<userName>{$username}</userName>
|
||||
<userPassword>{$password}</userPassword>
|
||||
<orderId>{$refId}</orderId>
|
||||
<saleOrderId>{$refId}</saleOrderId>
|
||||
<saleReferenceId>{$refId}</saleReferenceId>
|
||||
</int:bpVerifyRequest>
|
||||
<orderId>{$saleOrderId}</orderId>
|
||||
<saleOrderId>{$saleOrderId}</saleOrderId>
|
||||
<saleReferenceId>{$saleReferenceId}</saleReferenceId>
|
||||
</int:bpVerifySettleRequest>
|
||||
</soapenv:Body>
|
||||
</soapenv:Envelope>
|
||||
XML;
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Payment\Service;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Appointment\Repository\AppointmentRepository;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Payment\Entity\Payment;
|
||||
@@ -61,8 +60,10 @@ final class PaymentManager
|
||||
return false;
|
||||
}
|
||||
|
||||
// درگاه ملت orderId عددی (long) میخواهد؛ id عددیِ Payment را میفرستیم.
|
||||
// جستجوی پرداخت در callback از طریق query `order_id` (رشتهٔ ORD-…) انجام میشود.
|
||||
$callbackUrl = $this->callbackUrl($payment);
|
||||
$result = $gateway->initiate($payment->getAmountRials(), $payment->getOrderId(), $callbackUrl);
|
||||
$result = $gateway->initiate($payment->getAmountRials(), (string) $payment->getId(), $callbackUrl);
|
||||
|
||||
if (!$result->success) {
|
||||
if (!$testMode) {
|
||||
@@ -102,6 +103,21 @@ final class PaymentManager
|
||||
|
||||
$payment->setCallbackIp($clientIp);
|
||||
|
||||
// چک امنیتی اجباری مستند: مقادیر بازگشتی باید با مقادیر مرحلهٔ Pay همین
|
||||
// پرداخت بخوانند (ضد parameter tampering). فقط وقتی درگاه این فیلدها را
|
||||
// برمیگرداند اعمال میشود (ملت: RefId + SaleOrderId؛ سپ آنها را ندارد).
|
||||
$token = $payment->getGatewayToken();
|
||||
$refIdMismatch = $token !== null && isset($callbackData['RefId'])
|
||||
&& !hash_equals($token, (string) $callbackData['RefId']);
|
||||
$orderMismatch = isset($callbackData['SaleOrderId'])
|
||||
&& (string) $callbackData['SaleOrderId'] !== (string) $payment->getId();
|
||||
if ($refIdMismatch || $orderMismatch) {
|
||||
$payment->setStatus(Payment::STATUS_FAILED);
|
||||
$this->em->persist($payment);
|
||||
$this->log($payment, PaymentLog::ACTION_VERIFY, 'failed', null, $clientIp, ['reason' => 'tampering']);
|
||||
return $payment;
|
||||
}
|
||||
|
||||
$gateway = $this->gateways->resolve($gatewayName);
|
||||
$result = $gateway?->verify($callbackData);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user