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:
hamed
2026-07-02 18:45:34 +03:30
parent 6bb47d343d
commit 1b171a82f4
20 changed files with 1111 additions and 21 deletions
+38 -1
View File
@@ -37,6 +37,8 @@ class AdminApiController extends BaseController
private readonly EntityManagerInterface $em,
private readonly \App\Appointment\Service\SlotCalculatorService $slotCalculator,
private readonly \App\Insurance\Service\TenantInsuranceCleanupService $insuranceCleanup,
private readonly \App\Payment\Service\PaymentManager $paymentManager,
private readonly \App\Payment\Repository\PaymentRepository $paymentRepo,
) {}
// ── Users ─────────────────────────────────────────────────────────────────
@@ -891,7 +893,7 @@ class AdminApiController extends BaseController
{
$rows = $this->em->createQueryBuilder()
->select(
'p.uuid, p.orderId, p.amountRials, p.status, p.gateway, p.type, p.referenceId, p.createdAt, p.updatedAt',
'p.uuid, p.orderId, p.amountRials, p.status, p.gateway, p.type, p.referenceId, p.metadata, p.createdAt, p.updatedAt',
'u.mobileNumber as patient_mobile, u.realName as patient_name',
'a.uuid as appointment_uuid',
)
@@ -914,6 +916,8 @@ class AdminApiController extends BaseController
'gateway' => $p['gateway'],
'type' => $p['type'],
'ref_id' => $p['referenceId'],
'card_pan' => $p['metadata']['card_pan'] ?? null,
'refunds' => $p['metadata']['refunds'] ?? [],
'patient_mobile' => $p['patient_mobile'],
'patient_name' => $p['patient_name'],
'appointment_uuid' => $p['appointment_uuid'],
@@ -922,6 +926,39 @@ class AdminApiController extends BaseController
]);
}
#[Route('/api/v1/admin/payments/{uuid}/refund', methods: ['POST'])]
public function refundPayment(string $uuid, Request $request): JsonResponse
{
$payment = $this->paymentRepo->findOneBy(['uuid' => $uuid]);
if ($payment === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پرداخت یافت نشد', 404);
}
$amount = $request->toArray()['amount'] ?? null; // ریال؛ null = کل مبلغ
$result = $this->paymentManager->refundPayment(
$payment,
$amount !== null ? (int) $amount : null,
$request->getClientIp() ?? '',
);
return $result->success
? $this->success(['status' => $payment->getStatus(), 'refund_ref' => $result->refundRefId])
: $this->error(ErrorCodes::ERR_PAYMENT_002, $result->errorMessage, 422);
}
#[Route('/api/v1/admin/payments/{uuid}/reverse', methods: ['POST'])]
public function reversePayment(string $uuid, Request $request): JsonResponse
{
$payment = $this->paymentRepo->findOneBy(['uuid' => $uuid]);
if ($payment === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پرداخت یافت نشد', 404);
}
$result = $this->paymentManager->reversePayment($payment, $request->getClientIp() ?? '');
return $result->success
? $this->success(['status' => $payment->getStatus()])
: $this->error(ErrorCodes::ERR_PAYMENT_002, $result->errorMessage, 422);
}
// ── Representations ───────────────────────────────────────────────────────
#[OA\Get(
@@ -34,6 +34,7 @@ class SiteConfigController extends BaseController
'payment_test_mode',
'payment_allowed_frontend_hosts',
'mellat_enabled',
'mellat_sandbox',
'mellat_terminal_id',
'mellat_username',
'mellat_password',
+6 -2
View File
@@ -285,8 +285,11 @@ class PaymentController extends BaseController
public function callback(string $gateway, Request $request): \Symfony\Component\HttpFoundation\Response
{
$clientIp = $request->getClientIp() ?? '';
if (!$this->gateways->isTestMode() && !$this->isAllowedCallbackIp($clientIp)) {
return new JsonResponse(['success' => false, 'message' => 'دسترسی ممنوع'], 403);
// در حالت تست یا sandbox ملت، callback از IPی خارج از رنج شاپرک می‌آید؛ IP-check رد می‌شود.
$bypassIp = $this->gateways->isTestMode()
|| ($gateway === 'mellat' && $this->gateways->isMellatSandbox());
if (!$bypassIp && !$this->isAllowedCallbackIp($clientIp)) {
return $this->renderPaymentResult('forbidden');
}
$callbackData = array_merge($request->query->all(), $request->request->all());
@@ -551,6 +554,7 @@ class PaymentController extends BaseController
'invalid' => ['قابل پرداخت نیست', 'این سفارش در وضعیت قابل پرداخت نیست.'],
'gateway' => ['درگاه نامعتبر', 'درگاه پرداخت انتخابی نامعتبر یا غیرفعال است.'],
'invalid_return' => ['آدرس بازگشت نامعتبر', 'آدرس بازگشت مجاز نیست.'],
'forbidden' => ['دسترسی غیرمجاز', 'این درخواست از مبدأ مجاز ارسال نشده است.'],
];
[$title, $message] = $labels[$status] ?? ['خطا در پرداخت', 'خطایی در فرآیند پرداخت رخ داد.'];
+2
View File
@@ -16,6 +16,8 @@ class PaymentLog
{
public const ACTION_INITIATE = 'initiate';
public const ACTION_VERIFY = 'verify';
public const ACTION_REFUND = 'refund';
public const ACTION_REVERSE = 'reverse';
#[ORM\Id]
#[ORM\GeneratedValue]
+13
View File
@@ -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' => 'بانک ملت (آزمایشی)']];
}
+254 -14
View File
@@ -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);
+10
View File
@@ -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 = '',
) {}
}
+10
View File
@@ -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: 'برگشت وجه برای این درگاه پشتیبانی نمی‌شود');
}
}
+110
View File
@@ -9,6 +9,7 @@ use App\Payment\Entity\Payment;
use App\Payment\Entity\PaymentLog;
use App\Payment\Gateway\GatewayFactory;
use App\Payment\Gateway\PaymentInitResult;
use App\Payment\Gateway\PaymentRefundResult;
use App\Payment\Repository\PaymentLogRepository;
use App\Payment\Repository\PaymentRepository;
use App\Representation\Service\JalaliDateService;
@@ -155,6 +156,11 @@ final class PaymentManager
$payment->setStatus(Payment::STATUS_SUCCESS);
$payment->setReferenceId($result->referenceId);
// شمارهٔ کارت ماسک‌شدهٔ پرداخت‌کننده (ملت: CardHolderPan) برای نمایش در پنل.
$cardPan = $callbackData['CardHolderPan'] ?? $callbackData['SecurePan'] ?? null;
if ($cardPan) {
$payment->setMetadata(($payment->getMetadata() ?? []) + ['card_pan' => (string) $cardPan]);
}
$this->em->persist($payment);
$this->runPostAction($payment);
@@ -164,6 +170,76 @@ final class PaymentManager
});
}
/**
* استرداد وجه (کل یا جزئی) یک پرداخت موفق. داخل transaction + قفل ردیف.
* $amountRials = null → کل مبلغ. استرداد کامل → وضعیت refunded.
*/
public function refundPayment(Payment $payment, ?int $amountRials, string $clientIp): PaymentRefundResult
{
return $this->em->wrapInTransaction(function () use ($payment, $amountRials, $clientIp): PaymentRefundResult {
$locked = $this->paymentRepo->findByOrderIdForUpdate($payment->getOrderId());
if ($locked === null) {
return new PaymentRefundResult(false, errorMessage: 'پرداخت یافت نشد');
}
if ($locked->getStatus() !== Payment::STATUS_SUCCESS) {
return new PaymentRefundResult(false, errorMessage: 'فقط پرداخت موفق قابل استرداد است');
}
$total = $locked->getAmountRials();
$meta = $locked->getMetadata() ?? [];
$refunded = array_sum(array_column($meta['refunds'] ?? [], 'amount'));
$amount = $amountRials ?? ($total - $refunded); // null = باقی‌ماندهٔ قابل استرداد
if ($amount <= 0 || $refunded + $amount > $total) {
return new PaymentRefundResult(false, errorMessage: 'مبلغ استرداد نامعتبر است');
}
$gateway = $this->gateways->resolve($locked->getGateway());
$result = $gateway?->refund((string) $locked->getId(), (string) $locked->getReferenceId(), $amount)
?? new PaymentRefundResult(false, errorMessage: 'درگاه نامعتبر است');
if ($result->success) {
$meta['refunds'][] = ['amount' => $amount, 'ref' => $result->refundRefId, 'at' => time()];
$locked->setMetadata($meta);
// فقط استرداد کامل، post-action (نوبت/اشتراک/کیف‌پول) را معکوس می‌کند.
if ($refunded + $amount >= $total) {
$locked->setStatus(Payment::STATUS_REFUNDED);
$this->runReversePostAction($locked);
}
$this->em->persist($locked);
}
$this->log($locked, PaymentLog::ACTION_REFUND, $result->success ? 'success' : 'failed',
$result->refundRefId ?: null, $clientIp, ['amount' => $amount, 'error' => $result->errorMessage]);
return $result;
});
}
/** برگشت وجه یک پرداخت settle‌نشده. در موفقیت وضعیت refunded. داخل transaction + قفل. */
public function reversePayment(Payment $payment, string $clientIp): PaymentRefundResult
{
return $this->em->wrapInTransaction(function () use ($payment, $clientIp): PaymentRefundResult {
$locked = $this->paymentRepo->findByOrderIdForUpdate($payment->getOrderId());
if ($locked === null) {
return new PaymentRefundResult(false, errorMessage: 'پرداخت یافت نشد');
}
if ($locked->getStatus() !== Payment::STATUS_SUCCESS) {
return new PaymentRefundResult(false, errorMessage: 'فقط پرداخت موفق قابل برگشت است');
}
$gateway = $this->gateways->resolve($locked->getGateway());
$result = $gateway?->reverse((string) $locked->getId(), (string) $locked->getReferenceId())
?? new PaymentRefundResult(false, errorMessage: 'درگاه نامعتبر است');
if ($result->success) {
$locked->setStatus(Payment::STATUS_REFUNDED);
$this->runReversePostAction($locked);
$this->em->persist($locked);
}
$this->log($locked, PaymentLog::ACTION_REVERSE, $result->success ? 'success' : 'failed',
null, $clientIp, ['error' => $result->errorMessage]);
return $result;
});
}
public function callbackUrl(Payment $payment): string
{
$prefix = $payment->getType() === Payment::TYPE_SUBSCRIPTION
@@ -184,6 +260,40 @@ final class PaymentManager
};
}
/** معکوس‌سازی اثر پرداخت هنگام استرداد کامل / برگشت وجه. */
private function runReversePostAction(Payment $payment): void
{
match ($payment->getType()) {
Payment::TYPE_SUBSCRIPTION => $this->subscriptionService->deleteByPayment($payment),
Payment::TYPE_SMS_WALLET => $this->reverseSmsWalletCharge($payment),
Payment::TYPE_APPOINTMENT => $this->reverseAppointment($payment),
default => null,
};
}
private function reverseAppointment(Payment $payment): void
{
$appointment = $payment->getAppointment();
if ($appointment === null || !$appointment->canTransitionTo(Appointment::STATUS_CANCELLED_BY_USER)) {
return;
}
// لغو نوبت → اسلات به‌طور خودکار آزاد می‌شود (availability از نوبت‌های active محاسبه می‌شود).
$appointment->transitionTo(Appointment::STATUS_CANCELLED_BY_USER);
$this->em->persist($appointment);
}
private function reverseSmsWalletCharge(Payment $payment): void
{
$meta = $payment->getMetadata() ?? [];
$entityType = $meta['entity_type'] ?? null;
$entityId = isset($meta['entity_id']) ? (int) $meta['entity_id'] : null;
if ($entityType === null || $entityId === null) {
return;
}
$wallet = $this->smsWalletService->getOrCreate($entityType, $entityId);
$this->smsWalletService->deduct($wallet, $payment->getAmountRials(), 'استرداد شارژ کیف پیامک');
}
private function handleAppointmentConfirmation(Payment $payment): void
{
$appointment = $payment->getAppointment();
@@ -41,7 +41,7 @@ class SecurityHeadersSubscriber implements EventSubscriberInterface
$isPaymentPage
? "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; "
. "font-src https://cdn.jsdelivr.net data:; img-src data:; "
. "form-action https://*.shaparak.ir; base-uri 'none'"
. "form-action https://*.shaparak.ir https://sandbox.banktest.ir; base-uri 'none'"
: "default-src 'none'"
);
}
@@ -53,4 +53,15 @@ class ClinicSubscriptionRepository extends ServiceEntityRepository
$this->getEntityManager()->persist($subscription);
$this->getEntityManager()->flush();
}
public function findByPayment(\App\Payment\Entity\Payment $payment): ?ClinicSubscription
{
return $this->findOneBy(['payment' => $payment]);
}
public function remove(ClinicSubscription $subscription): void
{
$this->getEntityManager()->remove($subscription);
$this->getEntityManager()->flush();
}
}
@@ -115,6 +115,15 @@ class SubscriptionService
return $subscription;
}
/** حذف اشتراکِ ساخته‌شده از یک پرداخت (هنگام استرداد/برگشت وجه). */
public function deleteByPayment(\App\Payment\Entity\Payment $payment): void
{
$subscription = $this->subscriptionRepo->findByPayment($payment);
if ($subscription !== null) {
$this->subscriptionRepo->remove($subscription);
}
}
public function calculateExpiresAt(?int $currentExpiresAt, int $durationMonths): int
{
$base = max($currentExpiresAt ?? 0, time());