diff --git a/docs/api/payment.md b/docs/api/payment.md index 0fe850e2..11f15d29 100644 --- a/docs/api/payment.md +++ b/docs/api/payment.md @@ -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` diff --git a/src/Payment/Gateway/MellatGateway.php b/src/Payment/Gateway/MellatGateway.php index dac80e30..b9d3b042 100644 --- a/src/Payment/Gateway/MellatGateway.php +++ b/src/Payment/Gateway/MellatGateway.php @@ -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 << - + {$terminalId} {$username} {$password} - {$refId} - {$refId} - {$refId} - + {$saleOrderId} + {$saleOrderId} + {$saleReferenceId} + XML; diff --git a/src/Payment/Service/PaymentManager.php b/src/Payment/Service/PaymentManager.php index 95040036..4d8878d9 100644 --- a/src/Payment/Service/PaymentManager.php +++ b/src/Payment/Service/PaymentManager.php @@ -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);