feat(payment): update Mellat gateway implementation and enhance security checks
This commit is contained in:
@@ -56,6 +56,12 @@
|
||||
|
||||
**افزودن درگاه جدید (Open/Closed):** یک کلاس جدید implements `PaymentGatewayInterface` بساز، در `GatewayFactory::$gateways` + `LABELS` ثبت کن. `PaymentController`/`PaymentManager` تغییر نمیکنند.
|
||||
|
||||
**درگاه ملت (BPM) — نکات پیادهسازی طبق مستند رسمی ۱.۳۸:**
|
||||
- `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` میشود (این چک برای درگاههایی که این فیلدها را برنمیگردانند، مثل سپ، رد میشود).
|
||||
- **دامنهٔ callback/Referer:** ملت `Referer` و `callBackUrl` را با دامنهٔ ثبتشدهٔ پذیرنده مقایسه میکند؛ در صورت عدم تطابق خطای `62`. مطمئن شوید دامنهٔ بکاند = دامنهٔ ثبتشده نزد ملت.
|
||||
|
||||
---
|
||||
|
||||
## GET `/api/v1/payment/config`
|
||||
|
||||
@@ -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