Files
clinicpro/.claude/prompt/admin-payment-refund-reversal.md
T
hamed 1b171a82f4 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.
2026-07-02 18:45:34 +03:30

245 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# برگشت/استرداد وجه ملت از پنل ادمین (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).