Files
clinicpro/src/Payment/Gateway/MellatGateway.php
T
hamed ca71c49451 feat(payment): add payment detail endpoint and update payment model with order_id and patient_name
feat(appointment): enhance appointment detail page with time formatting and additional info
fix(payment): update payment query to fetch from the correct endpoint and adjust response structure
docs(api): add search parameter to payments API documentation and detail response structure
test(payment): add unit test for MellatGateway to verify null credentials handling
2026-07-02 15:10:15 +03:30

183 lines
6.6 KiB
PHP

<?php
namespace App\Payment\Gateway;
use App\Config\Repository\SiteConfigRepository;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class MellatGateway implements PaymentGatewayInterface
{
private const PAYMENT_URL = 'https://bpm.shaparak.ir/pgwchannel/startpay.mellat';
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly SiteConfigRepository $configRepo,
private readonly LoggerInterface $logger,
private readonly ?string $terminalId = '',
private readonly ?string $username = '',
private readonly ?string $password = '',
) {}
public function getName(): string
{
return 'mellat';
}
public function isConfigured(): bool
{
return $this->cfg('mellat_terminal_id', $this->terminalId) !== ''
&& $this->cfg('mellat_username', $this->username) !== ''
&& $this->cfg('mellat_password', $this->password) !== '';
}
private function cfg(string $key, ?string $envFallback): string
{
return (string) ($this->configRepo->get($key) ?: $envFallback ?? '');
}
public function initiate(int $amountRials, string $orderId, string $callbackUrl): PaymentInitResult
{
try {
$response = $this->httpClient->request(
'POST',
'https://bpm.shaparak.ir/pgwchannel/services/pgw?wsdl',
[
'body' => $this->buildRequestPayload($amountRials, $orderId, $callbackUrl),
'headers' => ['Content-Type' => 'text/xml; charset=utf-8'],
'timeout' => 10,
]
);
$resCode = $this->parseResCode($response->getContent());
if ($resCode !== '0') {
return new PaymentInitResult(false, errorMessage: "Mellat error: $resCode");
}
$refId = $this->parseRefId($response->getContent());
$redirectUrl = self::PAYMENT_URL . '?RefId=' . $refId;
// درگاه ملت باید با POST فرم (فیلد RefId) باز شود؛ redirectUrl (شامل RefId)
// برای سازگاری با مصرف‌کننده‌های قدیمی نگه داشته می‌شود.
return new PaymentInitResult(
true,
redirectUrl: $redirectUrl,
token: $refId,
redirectMethod: 'POST',
redirectParams: ['RefId' => $refId],
);
} catch (\Throwable $e) {
$this->logger->error(sprintf('Payment initiate failed (mellat): %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'orderId' => $orderId, 'amount' => $amountRials]);
return new PaymentInitResult(false, errorMessage: $e->getMessage());
}
}
public function verify(array $callbackData): PaymentVerifyResult
{
$refId = $callbackData['RefId'] ?? '';
$resCode = $callbackData['ResCode'] ?? '';
if ($resCode === '17') {
return new PaymentVerifyResult(false, errorMessage: 'پرداخت توسط کاربر لغو شد', canceled: true);
}
if ($resCode !== '0') {
return new PaymentVerifyResult(false, errorMessage: "Payment failed: $resCode");
}
try {
$response = $this->httpClient->request(
'POST',
'https://bpm.shaparak.ir/pgwchannel/services/pgw?wsdl',
[
'body' => $this->buildVerifyPayload($refId),
'headers' => ['Content-Type' => 'text/xml; charset=utf-8'],
'timeout' => 10,
]
);
$verifyCode = $this->parseResCode($response->getContent());
if ($verifyCode !== '0') {
return new PaymentVerifyResult(false, errorMessage: "Verify failed: $verifyCode");
}
return new PaymentVerifyResult(true, referenceId: $refId);
} catch (\Throwable $e) {
$this->logger->error(sprintf('Payment verify failed (mellat): %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'refId' => $refId]);
return new PaymentVerifyResult(false, errorMessage: $e->getMessage());
}
}
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;
}
private function buildVerifyPayload(string $refId): 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:bpVerifyRequest>
<terminalId>{$terminalId}</terminalId>
<userName>{$username}</userName>
<userPassword>{$password}</userPassword>
<orderId>{$refId}</orderId>
<saleOrderId>{$refId}</saleOrderId>
<saleReferenceId>{$refId}</saleReferenceId>
</int:bpVerifyRequest>
</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');
}
private function time(): string
{
return date('His');
}
}