feat(payment): add refund and reversal functionality to payment gateways
- Implemented `refund` and `reverse` methods in `PaymentGatewayInterface`. - Added `PaymentRefundResult` class to handle refund operation results. - Enhanced `MockGateway` and `SepGateway` to support refund and reversal operations. - Updated `PaymentManager` to include `refundPayment` and `reversePayment` methods for handling refunds and reversals in transactions. - Modified `ClinicSubscriptionRepository` and `SubscriptionService` to manage subscriptions during refunds. - Added admin API endpoints for processing refunds and reversals. - Updated security headers to allow form actions to the sandbox environment. - Documented the new refund and reversal features in the API documentation.
This commit is contained in:
@@ -39,12 +39,22 @@ class GatewayFactory
|
||||
return $this->configRepo->get('payment_test_mode') === '1';
|
||||
}
|
||||
|
||||
/** حالت sandbox ملت (banktest.ir) — اتصال واقعی، جدا از test_mode/Mock. موقت. */
|
||||
public function isMellatSandbox(): bool
|
||||
{
|
||||
return $this->configRepo->get('mellat_sandbox') === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* درگاهِ قابلاستفاده برای این نام؛ در حالت تست همیشه Mock، در غیر اینصورت
|
||||
* درگاه واقعی در صورت فعال بودن. null یعنی نامعتبر/غیرفعال.
|
||||
*/
|
||||
public function resolve(string $name): ?PaymentGatewayInterface
|
||||
{
|
||||
// sandbox ملت: اتصال واقعی به banktest، نه Mock.
|
||||
if ($name === 'mellat' && $this->isMellatSandbox()) {
|
||||
return $this->gateways['mellat'] ?? null;
|
||||
}
|
||||
if ($this->isTestMode()) {
|
||||
return $this->mock;
|
||||
}
|
||||
@@ -69,6 +79,9 @@ class GatewayFactory
|
||||
*/
|
||||
public function activeGateways(): array
|
||||
{
|
||||
if ($this->isMellatSandbox()) {
|
||||
return [['name' => 'mellat', 'label' => 'بانک ملت (Sandbox)']];
|
||||
}
|
||||
if ($this->isTestMode()) {
|
||||
return [['name' => 'mellat', 'label' => 'بانک ملت (آزمایشی)']];
|
||||
}
|
||||
|
||||
@@ -12,6 +12,16 @@ class MellatGateway implements PaymentGatewayInterface
|
||||
// endpoint سرویس SOAP (بدون ?wsdl؛ ?wsdl فقط توصیفِ سرویس است و POST به آن 500 میدهد).
|
||||
private const SERVICE_URL = 'https://bpm.shaparak.ir/pgwchannel/services/pgw';
|
||||
|
||||
// sandbox banktest.ir (موقت). پشت فلگ mellat_sandbox.
|
||||
// نکته: SOAP sandbox (pgwchannel) روی banktest 502 میدهد؛ فقط REST (ipg2) سالم است،
|
||||
// پس در حالت sandbox از REST (JSON + Basic Auth) استفاده میشود، نه SOAP.
|
||||
// نکته: API روی REST (ipg2) است ولی فرمِ پرداخت فقط روی pgwchannel/startpay.mellat سالم است (ipg2/startpay = 404).
|
||||
private const SANDBOX_REST_BASE = 'https://sandbox.banktest.ir/mellat/bpm.shaparak.ir/ipg2/rest';
|
||||
private const SANDBOX_PAYMENT_URL = 'https://sandbox.banktest.ir/mellat/bpm.shaparak.ir/pgwchannel/startpay.mellat';
|
||||
private const SANDBOX_TERMINAL_ID = '134759344';
|
||||
private const SANDBOX_USERNAME = 'user134759344';
|
||||
private const SANDBOX_PASSWORD = '17384843';
|
||||
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
private readonly SiteConfigRepository $configRepo,
|
||||
@@ -33,32 +43,97 @@ class MellatGateway implements PaymentGatewayInterface
|
||||
&& $this->cfg('mellat_password', $this->password) !== '';
|
||||
}
|
||||
|
||||
private function sandbox(): bool
|
||||
{
|
||||
return $this->configRepo->get('mellat_sandbox') === '1';
|
||||
}
|
||||
|
||||
private function paymentUrl(): string
|
||||
{
|
||||
return $this->sandbox() ? self::SANDBOX_PAYMENT_URL : self::PAYMENT_URL;
|
||||
}
|
||||
|
||||
/** هدر Basic Auth برای REST sandbox (base64 از userName:userPassword). */
|
||||
private function restHeaders(): array
|
||||
{
|
||||
$auth = base64_encode(self::SANDBOX_USERNAME . ':' . self::SANDBOX_PASSWORD);
|
||||
return ['Content-Type' => 'application/json', 'Authorization' => 'Basic ' . $auth];
|
||||
}
|
||||
|
||||
/** پاسخ REST یک رشتهٔ ساده مثل `0,RefId` یا `0` است (گاهی داخل "…"). */
|
||||
private function restParts(string $body): array
|
||||
{
|
||||
$body = trim($body, " \t\n\r\0\x0B\"");
|
||||
return array_map('trim', explode(',', $body));
|
||||
}
|
||||
|
||||
/** یک فراخوانی REST sandbox؛ فقط کد پاسخ (بخش اول) را برمیگرداند. */
|
||||
private function restCall(string $path, array $json): string
|
||||
{
|
||||
$body = $this->httpClient->request('POST', self::SANDBOX_REST_BASE . $path, [
|
||||
'json' => $json,
|
||||
'headers' => $this->restHeaders(),
|
||||
'timeout' => 10,
|
||||
])->getContent();
|
||||
|
||||
return $this->restParts($body)[0] ?? '-1';
|
||||
}
|
||||
|
||||
private function cfg(string $key, ?string $envFallback): string
|
||||
{
|
||||
// در sandbox، credentials از config خوانده نمیشوند تا مقادیر واقعیِ prod نشتی نکنند.
|
||||
if ($this->sandbox()) {
|
||||
return match ($key) {
|
||||
'mellat_terminal_id' => self::SANDBOX_TERMINAL_ID,
|
||||
'mellat_username' => self::SANDBOX_USERNAME,
|
||||
'mellat_password' => self::SANDBOX_PASSWORD,
|
||||
default => $envFallback ?? '',
|
||||
};
|
||||
}
|
||||
return (string) ($this->configRepo->get($key) ?: $envFallback ?? '');
|
||||
}
|
||||
|
||||
public function initiate(int $amountRials, string $orderId, string $callbackUrl): PaymentInitResult
|
||||
{
|
||||
try {
|
||||
$response = $this->httpClient->request(
|
||||
'POST',
|
||||
self::SERVICE_URL,
|
||||
[
|
||||
'body' => $this->buildRequestPayload($amountRials, $orderId, $callbackUrl),
|
||||
'headers' => ['Content-Type' => 'text/xml; charset=utf-8', 'SOAPAction' => '""'],
|
||||
'timeout' => 10,
|
||||
]
|
||||
);
|
||||
|
||||
$resCode = $this->parseResCode($response->getContent());
|
||||
if ($this->sandbox()) {
|
||||
[$resCode, $refId] = $this->restParts(
|
||||
$this->httpClient->request('POST', self::SANDBOX_REST_BASE . '/bpPayRequest', [
|
||||
'json' => [
|
||||
'terminalId' => (int) self::SANDBOX_TERMINAL_ID,
|
||||
'userName' => self::SANDBOX_USERNAME,
|
||||
'userPassword' => self::SANDBOX_PASSWORD,
|
||||
'orderId' => (int) $orderId,
|
||||
'amount' => $amountRials,
|
||||
'localDate' => $this->date(),
|
||||
'localTime' => $this->time(),
|
||||
'additionalData' => '',
|
||||
'callBackUrl' => $callbackUrl,
|
||||
'payerId' => '0',
|
||||
],
|
||||
'headers' => $this->restHeaders(),
|
||||
'timeout' => 10,
|
||||
])->getContent()
|
||||
) + ['-1', ''];
|
||||
} else {
|
||||
$response = $this->httpClient->request(
|
||||
'POST',
|
||||
self::SERVICE_URL,
|
||||
[
|
||||
'body' => $this->buildRequestPayload($amountRials, $orderId, $callbackUrl),
|
||||
'headers' => ['Content-Type' => 'text/xml; charset=utf-8', 'SOAPAction' => '""'],
|
||||
'timeout' => 10,
|
||||
]
|
||||
);
|
||||
$resCode = $this->parseResCode($response->getContent());
|
||||
$refId = $this->parseRefId($response->getContent());
|
||||
}
|
||||
|
||||
if ($resCode !== '0') {
|
||||
return new PaymentInitResult(false, errorMessage: "Mellat error: $resCode");
|
||||
}
|
||||
|
||||
$refId = $this->parseRefId($response->getContent());
|
||||
$redirectUrl = self::PAYMENT_URL . '?RefId=' . $refId;
|
||||
$redirectUrl = $this->paymentUrl() . '?RefId=' . $refId;
|
||||
|
||||
// درگاه ملت باید با POST فرم (فیلد RefId) باز شود؛ redirectUrl (شامل RefId)
|
||||
// برای سازگاری با مصرفکنندههای قدیمی نگه داشته میشود.
|
||||
@@ -96,6 +171,28 @@ class MellatGateway implements PaymentGatewayInterface
|
||||
}
|
||||
|
||||
try {
|
||||
if ($this->sandbox()) {
|
||||
// sandbox banktest متد ترکیبی bpVerifySettleRequest را پشتیبانی نمیکند (کد 44)؛
|
||||
// پس verify و settle جدا صدا زده میشوند. 0=موفق، 43=قبلاً verify، 45=قبلاً settle.
|
||||
$payload = [
|
||||
'terminalId' => (int) self::SANDBOX_TERMINAL_ID,
|
||||
'userName' => self::SANDBOX_USERNAME,
|
||||
'userPassword' => self::SANDBOX_PASSWORD,
|
||||
'orderId' => (int) $saleOrderId,
|
||||
'saleOrderId' => (int) $saleOrderId,
|
||||
'saleReferenceId' => (int) $saleReferenceId,
|
||||
];
|
||||
$vc = $this->restCall('/bpVerifyRequest', $payload);
|
||||
if (!in_array($vc, ['0', '43'], true)) {
|
||||
return new PaymentVerifyResult(false, errorMessage: "Verify failed: $vc");
|
||||
}
|
||||
$sc = $this->restCall('/bpSettleRequest', $payload);
|
||||
if (!in_array($sc, ['0', '45'], true)) {
|
||||
return new PaymentVerifyResult(false, errorMessage: "Settle failed: $sc");
|
||||
}
|
||||
return new PaymentVerifyResult(true, referenceId: $saleReferenceId);
|
||||
}
|
||||
|
||||
$response = $this->httpClient->request(
|
||||
'POST',
|
||||
self::SERVICE_URL,
|
||||
@@ -105,7 +202,6 @@ class MellatGateway implements PaymentGatewayInterface
|
||||
'timeout' => 10,
|
||||
]
|
||||
);
|
||||
|
||||
$verifyCode = $this->parseResCode($response->getContent());
|
||||
// 0 = موفق، 43 = پیشتر verify شده، 45 = پیشتر settle شده (هر دو idempotent = موفق).
|
||||
if (!in_array($verifyCode, ['0', '43', '45'], true)) {
|
||||
@@ -168,6 +264,150 @@ XML;
|
||||
XML;
|
||||
}
|
||||
|
||||
public function refund(string $saleOrderId, string $saleReferenceId, int $refundAmountRials): PaymentRefundResult
|
||||
{
|
||||
try {
|
||||
if ($this->sandbox()) {
|
||||
$parts = $this->restParts(
|
||||
$this->httpClient->request('POST', self::SANDBOX_REST_BASE . '/bpRefundRequest', [
|
||||
'json' => $this->refundPayload($saleOrderId, $saleReferenceId, $refundAmountRials),
|
||||
'headers' => $this->restHeaders(),
|
||||
'timeout' => 10,
|
||||
])->getContent()
|
||||
);
|
||||
} else {
|
||||
$xml = $this->httpClient->request('POST', self::SERVICE_URL, [
|
||||
'body' => $this->buildRefundPayload($saleOrderId, $saleReferenceId, $refundAmountRials),
|
||||
'headers' => ['Content-Type' => 'text/xml; charset=utf-8', 'SOAPAction' => '""'],
|
||||
'timeout' => 10,
|
||||
])->getContent();
|
||||
$parts = [$this->parseResCode($xml), $this->parseRefId($xml)];
|
||||
}
|
||||
$code = $parts[0] ?? '-1';
|
||||
if ($code !== '0') {
|
||||
return new PaymentRefundResult(false, errorMessage: $this->mellatMessage($code));
|
||||
}
|
||||
return new PaymentRefundResult(true, refundRefId: $parts[1] ?? '');
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Payment refund failed (mellat): ' . $e->getMessage(), ['saleReferenceId' => $saleReferenceId]);
|
||||
return new PaymentRefundResult(false, errorMessage: $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function reverse(string $saleOrderId, string $saleReferenceId): PaymentRefundResult
|
||||
{
|
||||
try {
|
||||
if ($this->sandbox()) {
|
||||
$code = $this->restCall('/bpReversalRequest', $this->reversalPayload($saleOrderId, $saleReferenceId));
|
||||
} else {
|
||||
$xml = $this->httpClient->request('POST', self::SERVICE_URL, [
|
||||
'body' => $this->buildReversalPayload($saleOrderId, $saleReferenceId),
|
||||
'headers' => ['Content-Type' => 'text/xml; charset=utf-8', 'SOAPAction' => '""'],
|
||||
'timeout' => 10,
|
||||
])->getContent();
|
||||
$code = $this->parseResCode($xml);
|
||||
}
|
||||
// 0 = موفق، 48 = پیشتر reverse شده (idempotent = موفق).
|
||||
if (!in_array($code, ['0', '48'], true)) {
|
||||
return new PaymentRefundResult(false, errorMessage: $this->mellatMessage($code));
|
||||
}
|
||||
return new PaymentRefundResult(true);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Payment reversal failed (mellat): ' . $e->getMessage(), ['saleReferenceId' => $saleReferenceId]);
|
||||
return new PaymentRefundResult(false, errorMessage: $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** نگاشت کد پاسخ ملت به پیام فارسی (جدول مستند ۱.۳۸). */
|
||||
private function mellatMessage(string $code): string
|
||||
{
|
||||
$map = [
|
||||
'11' => 'شماره کارت نامعتبر است',
|
||||
'12' => 'موجودی کافی نیست',
|
||||
'17' => 'کاربر از انجام تراکنش منصرف شده است',
|
||||
'19' => 'مبلغ استرداد بیش از مبلغ تراکنش خرید است',
|
||||
'21' => 'پذیرنده نامعتبر است',
|
||||
'24' => 'اطلاعات کاربری پذیرنده نامعتبر است',
|
||||
'25' => 'مبلغ نامعتبر است',
|
||||
'34' => 'خطای سیستمی درگاه (در محیط تست، استرداد پشتیبانی نمیشود)',
|
||||
'42' => 'تراکنش خرید (Sale) یافت نشد',
|
||||
'43' => 'این تراکنش پیشتر تأیید شده است',
|
||||
'44' => 'درخواست تأیید یافت نشد',
|
||||
'45' => 'این تراکنش پیشتر واریز (Settle) شده است',
|
||||
'46' => 'تراکنش واریز (Settle) نشده است',
|
||||
'47' => 'تراکنش واریز یافت نشد',
|
||||
'48' => 'این تراکنش پیشتر برگشت (Reverse) شده است',
|
||||
'51' => 'تراکنش تکراری است',
|
||||
'61' => 'خطا در واریز',
|
||||
'62' => 'مسیر بازگشت در دامنهٔ ثبتشدهٔ پذیرنده نیست',
|
||||
];
|
||||
return ($map[$code] ?? 'خطای درگاه') . " (کد $code)";
|
||||
}
|
||||
|
||||
/** orderId یکتای عددی برای هر درخواست refund/reverse (مستند: هر بار باید یکتا باشد). */
|
||||
private function uniqueOrderId(): int
|
||||
{
|
||||
return (int) substr((string) (int) (microtime(true) * 1000), -12);
|
||||
}
|
||||
|
||||
/** بدنهٔ JSON مشترک refund (REST). */
|
||||
private function refundPayload(string $saleOrderId, string $saleReferenceId, int $refundAmountRials): array
|
||||
{
|
||||
return $this->reversalPayload($saleOrderId, $saleReferenceId) + ['refundAmount' => $refundAmountRials];
|
||||
}
|
||||
|
||||
/** بدنهٔ JSON مشترک reverse (REST). */
|
||||
private function reversalPayload(string $saleOrderId, string $saleReferenceId): array
|
||||
{
|
||||
return [
|
||||
'terminalId' => (int) $this->cfg('mellat_terminal_id', $this->terminalId),
|
||||
'userName' => $this->cfg('mellat_username', $this->username),
|
||||
'userPassword' => $this->cfg('mellat_password', $this->password),
|
||||
'orderId' => $this->uniqueOrderId(),
|
||||
'saleOrderId' => (int) $saleOrderId,
|
||||
'saleReferenceId' => (int) $saleReferenceId,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildRefundPayload(string $saleOrderId, string $saleReferenceId, int $refundAmountRials): string
|
||||
{
|
||||
$p = $this->reversalPayload($saleOrderId, $saleReferenceId);
|
||||
return <<<XML
|
||||
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:int="http://interfaces.core.sw.bsl.com/westernmellat">
|
||||
<soapenv:Body>
|
||||
<int:bpRefundRequest>
|
||||
<terminalId>{$p['terminalId']}</terminalId>
|
||||
<userName>{$p['userName']}</userName>
|
||||
<userPassword>{$p['userPassword']}</userPassword>
|
||||
<orderId>{$p['orderId']}</orderId>
|
||||
<saleOrderId>{$p['saleOrderId']}</saleOrderId>
|
||||
<saleReferenceId>{$p['saleReferenceId']}</saleReferenceId>
|
||||
<refundAmount>{$refundAmountRials}</refundAmount>
|
||||
</int:bpRefundRequest>
|
||||
</soapenv:Body>
|
||||
</soapenv:Envelope>
|
||||
XML;
|
||||
}
|
||||
|
||||
private function buildReversalPayload(string $saleOrderId, string $saleReferenceId): string
|
||||
{
|
||||
$p = $this->reversalPayload($saleOrderId, $saleReferenceId);
|
||||
return <<<XML
|
||||
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:int="http://interfaces.core.sw.bsl.com/westernmellat">
|
||||
<soapenv:Body>
|
||||
<int:bpReversalRequest>
|
||||
<terminalId>{$p['terminalId']}</terminalId>
|
||||
<userName>{$p['userName']}</userName>
|
||||
<userPassword>{$p['userPassword']}</userPassword>
|
||||
<orderId>{$p['orderId']}</orderId>
|
||||
<saleOrderId>{$p['saleOrderId']}</saleOrderId>
|
||||
<saleReferenceId>{$p['saleReferenceId']}</saleReferenceId>
|
||||
</int:bpReversalRequest>
|
||||
</soapenv:Body>
|
||||
</soapenv:Envelope>
|
||||
XML;
|
||||
}
|
||||
|
||||
private function parseResCode(string $xml): string
|
||||
{
|
||||
preg_match('/<return>(.*?)<\/return>/', $xml, $m);
|
||||
|
||||
@@ -35,4 +35,14 @@ class MockGateway implements PaymentGatewayInterface
|
||||
|
||||
return new PaymentVerifyResult(true, referenceId: $refId, amountRials: $amount);
|
||||
}
|
||||
|
||||
public function refund(string $saleOrderId, string $saleReferenceId, int $refundAmountRials): PaymentRefundResult
|
||||
{
|
||||
return new PaymentRefundResult(true, refundRefId: 'MOCK-REFUND-' . $saleOrderId);
|
||||
}
|
||||
|
||||
public function reverse(string $saleOrderId, string $saleReferenceId): PaymentRefundResult
|
||||
{
|
||||
return new PaymentRefundResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,4 +20,14 @@ interface PaymentGatewayInterface
|
||||
* Verifies callback and confirms payment.
|
||||
*/
|
||||
public function verify(array $callbackData): PaymentVerifyResult;
|
||||
|
||||
/**
|
||||
* Refunds a settled transaction (full or partial). Amounts in Rials.
|
||||
*/
|
||||
public function refund(string $saleOrderId, string $saleReferenceId, int $refundAmountRials): PaymentRefundResult;
|
||||
|
||||
/**
|
||||
* Reverses a not-yet-settled transaction.
|
||||
*/
|
||||
public function reverse(string $saleOrderId, string $saleReferenceId): PaymentRefundResult;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Payment\Gateway;
|
||||
|
||||
final class PaymentRefundResult
|
||||
{
|
||||
public function __construct(
|
||||
public readonly bool $success,
|
||||
public readonly string $refundRefId = '', // شماره پیگیری استرداد (refund؛ reversal خالی)
|
||||
public readonly string $errorMessage = '',
|
||||
) {}
|
||||
}
|
||||
@@ -99,4 +99,14 @@ class SepGateway implements PaymentGatewayInterface
|
||||
return new PaymentVerifyResult(false, errorMessage: $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function refund(string $saleOrderId, string $saleReferenceId, int $refundAmountRials): PaymentRefundResult
|
||||
{
|
||||
return new PaymentRefundResult(false, errorMessage: 'استرداد برای این درگاه پشتیبانی نمیشود');
|
||||
}
|
||||
|
||||
public function reverse(string $saleOrderId, string $saleReferenceId): PaymentRefundResult
|
||||
{
|
||||
return new PaymentRefundResult(false, errorMessage: 'برگشت وجه برای این درگاه پشتیبانی نمیشود');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user