feat(payment): update Mellat gateway implementation and enhance security checks

This commit is contained in:
hamed
2026-07-02 17:51:50 +03:30
parent e487f7437a
commit 6bb47d343d
3 changed files with 51 additions and 18 deletions
+6
View File
@@ -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`
+27 -16
View File
@@ -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;
+18 -2
View File
@@ -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);