# برگشت/استرداد وجه ملت از پنل ادمین (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` ```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) ```php 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 ```php private function log(Payment $payment, string $action, string $result, ?string $authority, ?string $clientIp, ?array $payload): void // processCallback داخل $this->em->wrapInTransaction(...) با قفل بدبینانه اجرا می‌شود ``` ### `AdminApiController::paymentDetail` — الان فقط GET ```php #[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`): ```php final class PaymentRefundResult { public function __construct( public readonly bool $success, public readonly string $refundRefId = '', // شماره پیگیری استرداد (refund؛ reversal خالی) public readonly string $errorMessage = '', ) {} } ``` ### ۲. interface — دو متد جدید ```php 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 اضافه کن: ```php // orderId یکتا برای درخواست‌های refund/reverse (مستند: هر بار باید یکتا باشد). private function uniqueOrderId(string $saleOrderId): int { // ترکیب saleOrderId با میکروثانیه، محدود به رنج long. return (int) (substr((string) (int) (microtime(true) * 1000), -12)); } ``` **refund:** ```php 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` — اکشن‌های جدید ```php public const ACTION_REFUND = 'refund'; public const ACTION_REVERSE = 'reverse'; ``` ### ۵. `PaymentManager` — refundPayment / reversePayment داخل transaction، فقط پرداخت `success` قابل استرداد است. مبلغ پیش‌فرض = کل مبلغ؛ مبلغ جزئی معتبر (`0 < amount <= payment.amountRials`). ```php 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 ```php #[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` طبق مستند ۱.۳۸؛ پاسخ refund `0,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).