- 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.
15 KiB
برگشت/استرداد وجه ملت از پنل ادمین (bpReversalRequest / bpRefundRequest)
پروژه
clinicpro (backend Payment + پنل ادمین React)
زمینه
درگاه ملت دو متد برای عودت وجه دارد:
bpReversalRequest(برگشت وجه / Reversal): وقتی تراکنش هنوز settle نشده است (قبل از واریز به حساب پذیرنده). باید بعد ازbpVerifyRequestو حداکثر تا پایان روز جاری (بهشرط عدم settle) فراخوانی شود. ورودی:terminalId, userName, userPassword, orderId (یکتا), saleOrderId, saleReferenceId. خروجی = یک کد پاسخ (0= موفق).bpRefundRequest(استرداد وجه / Refund): وقتی تراکنش قبلاً settle شده (bpSettleRequestانجام شده). میتواند کل یا بخشی از مبلغ را به کارت مشتری برگرداند و چند بار (تا سقف مبلغ خرید) قابل فراخوانی است. ورودی:... orderId (یکتا), saleOrderId, saleReferenceId, refundAmount. خروجی = رشتهٔ0,RefId(کد پاسخ + شماره پیگیری استرداد). نکته مهم مستند: کد0فقط «پذیرش اولیهٔ درخواست» است، نه عودت نهایی؛ وضعیت نهایی باید از سرویس استعلام ریفاند پیگیری شود.
در جریان فعلی ما، پرداختِ موفق بلافاصله verify+settle میشود (MellatGateway::verify)، پس تراکنشهای success ما معمولاً settleشدهاند → مسیر اصلی Refund است. Reversal برای حالت لبهای (settle نشده) نگه داشته میشود.
هدف: ادمین بتواند از صفحهٔ جزئیات پرداخت (/admin/payments/{uuid}) یک پرداخت موفق ملت را استرداد (کل/جزئی) یا برگشت بزند.
فایلهای مرتبط
| فایل | نقش |
|---|---|
clinicpro/src/Payment/Gateway/PaymentGatewayInterface.php |
افزودن متدهای refund() / reverse() |
clinicpro/src/Payment/Gateway/PaymentRefundResult.php |
جدید — نتیجهٔ refund/reverse |
clinicpro/src/Payment/Gateway/MellatGateway.php |
پیادهسازی refund/reverse (REST sandbox + SOAP prod) |
clinicpro/src/Payment/Gateway/SepGateway.php / MockGateway.php |
پیادهسازی no-op/mock برای رعایت interface |
clinicpro/src/Payment/Entity/PaymentLog.php |
افزودن ACTION_REFUND / ACTION_REVERSE |
clinicpro/src/Payment/Service/PaymentManager.php |
متدهای refundPayment() / reversePayment() |
clinicpro/src/Admin/Controller/AdminApiController.php |
endpointهای POST .../payments/{uuid}/refund و /reverse |
clinicpro/assets/admin/pages/PaymentDetailPage.tsx |
دکمههای «استرداد وجه» / «برگشت وجه» |
clinicpro/assets/admin/types/index.ts |
تایپ نتیجه (در صورت نیاز) |
clinicpro/docs/api/admin.md + docs/api/payment.md |
مستندسازی |
وضعیت فعلی
PaymentGatewayInterface.php
interface PaymentGatewayInterface
{
public function getName(): string;
public function isConfigured(): bool;
public function initiate(int $amountRials, string $orderId, string $callbackUrl): PaymentInitResult;
public function verify(array $callbackData): PaymentVerifyResult;
}
MellatGateway.php — الگوی موجود REST/SOAP (پس از کار sandbox)
private const SANDBOX_REST_BASE = 'https://sandbox.banktest.ir/mellat/bpm.shaparak.ir/ipg2/rest';
private const SERVICE_URL = 'https://bpm.shaparak.ir/pgwchannel/services/pgw'; // prod SOAP
private function sandbox(): bool { return $this->configRepo->get('mellat_sandbox') === '1'; }
private function restHeaders(): array { /* Basic Auth base64(user:pass) */ }
private function restCall(string $path, array $json): string { /* POST؛ برمیگرداند restParts()[0] (کد پاسخ) */ }
private function restParts(string $body): array { /* `0,RefId` → ['0','RefId'] */ }
private function cfg(string $key, ?string $envFallback): string { /* sandbox → const creds */ }
در sandbox، verify از دو فراخوانی جدا
bpVerifyRequest+bpSettleRequestاستفاده میکند (متد ترکیبی 44 میدهد). refund/reverse هم باید همان الگوی sandbox=REST / prod=SOAP را رعایت کند.
PaymentManager.php — الگوی log و transaction
private function log(Payment $payment, string $action, string $result, ?string $authority, ?string $clientIp, ?array $payload): void
// processCallback داخل $this->em->wrapInTransaction(...) با قفل بدبینانه اجرا میشود
AdminApiController::paymentDetail — الان فقط GET
#[Route('/api/v1/admin/payments/{uuid}', methods: ['GET'])]
public function paymentDetail(string $uuid): JsonResponse { ... 'card_pan' => ..., 'ref_id' => $p['referenceId'], ... }
وظایف
۱. کلاس نتیجهٔ refund/reverse
clinicpro/src/Payment/Gateway/PaymentRefundResult.php (مشابه PaymentVerifyResult):
final class PaymentRefundResult
{
public function __construct(
public readonly bool $success,
public readonly string $refundRefId = '', // شماره پیگیری استرداد (refund؛ reversal خالی)
public readonly string $errorMessage = '',
) {}
}
۲. interface — دو متد جدید
public function refund(string $saleOrderId, string $saleReferenceId, int $refundAmountRials): PaymentRefundResult;
public function reverse(string $saleOrderId, string $saleReferenceId): PaymentRefundResult;
SepGateway و MockGateway هم باید پیاده کنند:
MockGateway: همیشهnew PaymentRefundResult(true, refundRefId: 'MOCK-REFUND-'.$saleOrderId)(تا در حالت test هم قابل تست باشد).SepGateway:new PaymentRefundResult(false, errorMessage: 'استرداد برای این درگاه پشتیبانی نمیشود')(فعلاً).
۳. MellatGateway — refund/reverse
هر دو باید orderId یکتای عددی بفرستند (برای هر فراخوانی متفاوت). یک helper اضافه کن:
// orderId یکتا برای درخواستهای refund/reverse (مستند: هر بار باید یکتا باشد).
private function uniqueOrderId(string $saleOrderId): int
{
// ترکیب saleOrderId با میکروثانیه، محدود به رنج long.
return (int) (substr((string) (int) (microtime(true) * 1000), -12));
}
refund:
public function refund(string $saleOrderId, string $saleReferenceId, int $refundAmountRials): PaymentRefundResult
{
$payload = [
'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),
'saleOrderId' => (int) $saleOrderId,
'saleReferenceId' => (int) $saleReferenceId,
'refundAmount' => $refundAmountRials,
];
try {
if ($this->sandbox()) {
$parts = $this->restParts($this->httpClient->request('POST',
self::SANDBOX_REST_BASE . '/bpRefundRequest',
['json' => $payload, 'headers' => $this->restHeaders(), 'timeout' => 10]
)->getContent());
} else {
// prod: SOAP bpRefundRequest؛ پاسخ `0,RefId` را با parseResCode/parseRefId بخوان.
$xml = $this->httpClient->request('POST', self::SERVICE_URL, [
'body' => $this->buildRefundPayload($payload),
'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: "Refund failed: $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());
}
}
reverse: مثل refund ولی بدون refundAmount، endpoint bpReversalRequest، پاسخ فقط کد (0 موفق)، بدون RefId. 0/48 (48 = قبلاً reverse شده) را موفق در نظر بگیر.
برای prod، متدهای buildRefundPayload() / buildReversalPayload() را مشابه buildVerifySettlePayload() با نام تگ SOAP درست (bpRefundRequest / bpReversalRequest) بساز. اگر میخواهی دامنهٔ این تسک را کوچک نگه داری، در حالت prod میتوانی موقتاً PaymentRefundResult(false, 'در prod هنوز فعال نشده') برگردانی و فقط sandbox را کامل پیاده کنی — ولی ترجیح اینه هر دو پیاده شوند.
۴. PaymentLog — اکشنهای جدید
public const ACTION_REFUND = 'refund';
public const ACTION_REVERSE = 'reverse';
۵. PaymentManager — refundPayment / reversePayment
داخل transaction، فقط پرداخت success قابل استرداد است. مبلغ پیشفرض = کل مبلغ؛ مبلغ جزئی معتبر (0 < amount <= payment.amountRials).
public function refundPayment(Payment $payment, ?int $amountRials, string $clientIp): PaymentRefundResult
{
if ($payment->getStatus() !== Payment::STATUS_SUCCESS) {
return new PaymentRefundResult(false, errorMessage: 'فقط پرداخت موفق قابل استرداد است');
}
$amount = $amountRials ?? $payment->getAmountRials();
if ($amount <= 0 || $amount > $payment->getAmountRials()) {
return new PaymentRefundResult(false, errorMessage: 'مبلغ استرداد نامعتبر است');
}
$gateway = $this->gateways->resolve($payment->getGateway());
$result = $gateway?->refund((string) $payment->getId(), (string) $payment->getReferenceId(), $amount)
?? new PaymentRefundResult(false, errorMessage: 'درگاه نامعتبر');
if ($result->success) {
// استرداد کامل → وضعیت refunded؛ جزئی → success میماند ولی در metadata ثبت میشود.
if ($amount >= $payment->getAmountRials()) {
$payment->setStatus(Payment::STATUS_REFUNDED);
}
$meta = $payment->getMetadata() ?? [];
$meta['refunds'][] = ['amount' => $amount, 'ref' => $result->refundRefId, 'at' => time()];
$payment->setMetadata($meta);
$this->em->persist($payment);
}
$this->log($payment, PaymentLog::ACTION_REFUND, $result->success ? 'success' : 'failed',
$result->refundRefId ?: null, $clientIp, ['amount' => $amount, 'error' => $result->errorMessage]);
return $result;
}
reversePayment(Payment, clientIp) مشابه: بدون amount، در موفقیت status = STATUS_REFUNDED (یا در صورت افزودن، reversed)، log با ACTION_REVERSE.
نکته: هر دو متد باید داخل
$this->em->wrapInTransaction(...)با قفل ردیف (findByOrderIdForUpdateیا معادل) اجرا شوند تا با callback رقابت نکنند. الگویprocessCallbackرا دنبال کن.
۶. Admin endpoints
#[IsGranted('ROLE_ADMIN')]
#[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_XXX, $result->errorMessage, 422);
}
مشابه برای /reverse (بدون body amount). از ErrorCodes موجود مناسب استفاده کن (یا کد پرداخت موجود).
۷. Frontend — PaymentDetailPage.tsx
- دو دکمه فقط وقتی
payment.status === 'success' && payment.gateway === 'mellat':- «استرداد وجه» (
btnقرمز/warning) →ConfirmDialog+useMutationرویPOST /api/v1/admin/payments/{uuid}/refund(بدنه خالی = کل مبلغ؛ اختیاری: input مبلغ جزئی). - «برگشت وجه» (Reversal) →
POST .../reverse.
- «استرداد وجه» (
- بعد از موفقیت:
queryClient.invalidateQueries(['payment', uuid])+ toast. - اگر پرداخت
refundedشد، دکمهها مخفی و بخش «استردادها» (ازmetadata.refundsاگر در detail برگردانده شود) نمایش داده شود. - الگوی موجود: TanStack Query
useMutation،api.post،ConfirmDialog، کلاسهای موجود (btn,badge).
نکات مهم
- قفل + transaction: refund/reverse مثل verify باید atomic باشند تا با callback/refund همزمان تداخل نکنند.
- idempotency ملت: reverse کد
48(قبلاً reverse) = موفق؛ refund کد0فقط پذیرش اولیه است (نه عودت نهایی) — در UI پیام «درخواست استرداد ثبت شد؛ عودت نهایی طی چند روز» نشان بده، نه «انجام شد». - مبلغ به ریال است (مثل بقیهٔ سیستم).
refundAmountملت هم ریال. - sandbox:
mellat_sandbox=1؛ endpointها.../ipg2/rest/bpRefundRequestو.../ipg2/rest/bpReversalRequestبا Basic Auth. تست: بعد از یک پرداخت موفق sandbox، از پنل استرداد بزن و پاسخ0,RefIdرا ببین. - prod SOAP: تگهای
bpRefundRequest/bpReversalRequestطبق مستند ۱.۳۸؛ پاسخ refund0,RefId، reverse فقط کد. - مجوز: فقط
ROLE_ADMIN. - استرداد جزئی چندباره: مجموع نباید از مبلغ خرید بیشتر شود؛ در
metadata.refunds[]جمع بزن و چک کن. - بعد از تغییر
src/Payment/*وsrc/Admin/*→docs/api/payment.mdوdocs/api/admin.mdرا بهروز کن. - migration لازم نیست (فقط metadata JSON استفاده میشود؛ اگر status جدید
reversedاضافه کردی هم enum در کد است نه DB).