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
@@ -0,0 +1,244 @@
# برگشت/استرداد وجه ملت از پنل ادمین (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).
+264
View File
@@ -0,0 +1,264 @@
# فعال‌سازی درگاه sandbox بانک ملت (banktest.ir) — موقت
## پروژه
`clinicpro` (backend / Payment)
> **مهم:** این تغییر **موقتی** است و باید روی یک **branch جدا** انجام شود (مثلاً `feature/mellat-sandbox`). هدف: تست کامل چرخهٔ پرداخت ملت بدون درگاه واقعی، با استفاده از sandbox شرکت banktest.ir که SOAP/REST ملت را شبیه‌سازی می‌کند. بعد از اتمام تست باید به‌راحتی برگردانده شود؛ پس تغییرات را کمینه و برگشت‌پذیر نگه دار (یک فلگ روشن/خاموش).
## زمینه
`MellatGateway` فعلاً روی endpoint **عملیاتی** ملت هاردکد است:
- SOAP service: `https://bpm.shaparak.ir/pgwchannel/services/pgw`
- فرم پرداخت: `https://bpm.shaparak.ir/pgwchannel/startpay.mellat`
تست واقعی روی سرور بدون ترمینال/دامنهٔ ثبت‌شده ممکن نیست (خطای ۶۲ / ۲۱ / ۲۴). banktest.ir یک sandbox می‌دهد که **همان کانال SOAP `pgwchannel`** ملت را روی دامنهٔ خودش mirror می‌کند و credentials آزمایشی می‌دهد. چون کد فعلی ما SOAP روی `pgwchannel` است، فقط با **سوییچ base-URL + credentials** به sandbox وصل می‌شود — بدون بازنویسی منطق XML/parse.
### اطلاعات sandbox (banktest.ir)
| مورد | مقدار |
|------|-------|
| terminalId | `134759344` |
| userName | `user134759344` |
| userPassword | `17384843` |
| WSDL | `https://sandbox.banktest.ir/mellat/bpm.shaparak.ir/pgwchannel/services/pgw?wsdl` |
| SOAP service (بدون `?wsdl`) | `https://sandbox.banktest.ir/mellat/bpm.shaparak.ir/pgwchannel/services/pgw` |
| فرم پرداخت | `https://sandbox.banktest.ir/mellat/bpm.shaparak.ir/pgwchannel/startpay.mellat` |
> نکته: sandbox متد REST هم دارد (`.../ipg2/rest/...`) و «جدیدترین روش» است، ولی برای کمینه‌کردن تغییر و برگشت‌پذیری، **از همان مسیر SOAP `pgwchannel` استفاده کن** که کد فعلی با آن سازگار است. REST را پیاده نکن مگر اینکه SOAP sandbox کار نکند.
## مشکل / هدف
یک فلگ تنظیماتی `mellat_sandbox` اضافه کن که وقتی `'1'` است:
1. `MellatGateway` به base-URLهای sandbox banktest.ir وصل شود (SOAP service + فرم پرداخت).
2. credentials از مقادیر sandbox بالا استفاده شوند (به‌صورت fallback، تا نیازی به دست‌کاری تنظیمات prod نباشد).
3. درگاه `mellat` به‌عنوان درگاه قابل‌انتخاب و «configured» شناخته شود، **بدون** رفتن به `payment_test_mode` (که `MockGateway` را اجبار می‌کند و اصلاً به بانک وصل نمی‌شود).
4. CSP اجازهٔ `form-action` به `sandbox.banktest.ir` بدهد تا فرم auto-submit به sandbox باز شود.
خاموش‌بودن فلگ = رفتار دقیقاً مثل قبل (prod).
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `clinicpro/src/Payment/Gateway/MellatGateway.php` | سوییچ base-URL + credentials sandbox |
| `clinicpro/src/Payment/Gateway/GatewayFactory.php` | اجازهٔ استفاده از mellat در حالت sandbox (بدون Mock) |
| `clinicpro/src/Shared/EventSubscriber/SecurityHeadersSubscriber.php` | افزودن `sandbox.banktest.ir` به `form-action` |
| `clinicpro/src/Config/Controller/SiteConfigController.php` | افزودن `mellat_sandbox` به `ALLOWED_KEYS` (تا از admin settings toggle شود) |
| `clinicpro/docs/api/payment.md` | مستندسازی حالت sandbox |
## وضعیت فعلی
### `MellatGateway.php` (خطوط کلیدی)
```php
class MellatGateway implements PaymentGatewayInterface
{
private const PAYMENT_URL = 'https://bpm.shaparak.ir/pgwchannel/startpay.mellat';
private const SERVICE_URL = 'https://bpm.shaparak.ir/pgwchannel/services/pgw';
public function isConfigured(): bool
{
return $this->cfg('mellat_terminal_id', $this->terminalId) !== ''
&& $this->cfg('mellat_username', $this->username) !== ''
&& $this->cfg('mellat_password', $this->password) !== '';
}
private function cfg(string $key, ?string $envFallback): string
{
return (string) ($this->configRepo->get($key) ?: $envFallback ?? '');
}
public function initiate(...) {
$response = $this->httpClient->request('POST', self::SERVICE_URL, [...]);
...
$redirectUrl = self::PAYMENT_URL . '?RefId=' . $refId;
}
public function verify(...) {
$response = $this->httpClient->request('POST', self::SERVICE_URL, [...]);
}
// buildRequestPayload / buildVerifySettlePayload از cfg('mellat_terminal_id'...) می‌خوانند
}
```
### `GatewayFactory.php` — `resolve()` در حالت test_mode همیشه Mock می‌دهد
```php
public function resolve(string $name): ?PaymentGatewayInterface
{
if ($this->isTestMode()) {
return $this->mock; // ← مانع اتصال واقعی به sandbox
}
if (!$this->isEnabled($name)) {
return null;
}
return $this->gateways[$name] ?? null;
}
public function activeGateways(): array
{
if ($this->isTestMode()) {
return [['name' => 'mellat', 'label' => 'بانک ملت (آزمایشی)']];
}
$out = [];
foreach ($this->gateways as $name => $gateway) {
if ($gateway->isConfigured() && $this->isEnabled($name)) {
$out[] = ['name' => $name, 'label' => self::LABELS[$name] ?? $name];
}
}
return $out;
}
```
### `SecurityHeadersSubscriber.php` (خط ۴۴)
```php
. "form-action https://*.shaparak.ir; base-uri 'none'"
```
## وظایف
### ۱. `MellatGateway` — سوییچ sandbox
`PAYMENT_URL` و `SERVICE_URL` را از `const` به **متد** تبدیل کن که بر اساس فلگ `mellat_sandbox` مقدار برمی‌گرداند. credentials هم وقتی sandbox روشن است از ثابت‌های sandbox به‌عنوان fallback استفاده کنند (اولویت همچنان با config key اگر ست شده باشد).
```php
class MellatGateway implements PaymentGatewayInterface
{
// prod (پیش‌فرض)
private const PAYMENT_URL = 'https://bpm.shaparak.ir/pgwchannel/startpay.mellat';
private const SERVICE_URL = 'https://bpm.shaparak.ir/pgwchannel/services/pgw';
// sandbox banktest.ir (موقت — همان کانال SOAP pgwchannel)
private const SANDBOX_PAYMENT_URL = 'https://sandbox.banktest.ir/mellat/bpm.shaparak.ir/pgwchannel/startpay.mellat';
private const SANDBOX_SERVICE_URL = 'https://sandbox.banktest.ir/mellat/bpm.shaparak.ir/pgwchannel/services/pgw';
private const SANDBOX_TERMINAL_ID = '134759344';
private const SANDBOX_USERNAME = 'user134759344';
private const SANDBOX_PASSWORD = '17384843';
private function sandbox(): bool
{
return $this->configRepo->get('mellat_sandbox') === '1';
}
private function serviceUrl(): string
{
return $this->sandbox() ? self::SANDBOX_SERVICE_URL : self::SERVICE_URL;
}
private function paymentUrl(): string
{
return $this->sandbox() ? self::SANDBOX_PAYMENT_URL : self::PAYMENT_URL;
}
```
سپس:
- در `initiate()` و `verify()` به‌جای `self::SERVICE_URL` از `$this->serviceUrl()` استفاده کن.
- در `initiate()` به‌جای `self::PAYMENT_URL` از `$this->paymentUrl()` استفاده کن.
- در `cfg()` وقتی sandbox روشن است، fallback را به مقدار sandbox بده. تمیزترین راه: یک helper که envFallback را در حالت sandbox override کند:
```php
private function cfg(string $key, ?string $envFallback): string
{
if ($this->sandbox()) {
$envFallback = match ($key) {
'mellat_terminal_id' => self::SANDBOX_TERMINAL_ID,
'mellat_username' => self::SANDBOX_USERNAME,
'mellat_password' => self::SANDBOX_PASSWORD,
default => $envFallback,
};
// در sandbox، config مقدار prod را override نکند:
return $envFallback ?? '';
}
return (string) ($this->configRepo->get($key) ?: $envFallback ?? '');
}
```
> توجه: در حالت sandbox عمداً `configRepo` را نادیده می‌گیریم تا credentials واقعیِ prod (اگر در تنظیمات ست شده باشند) روی sandbox نشتی نکنند. `isConfigured()` بدون تغییر می‌ماند و چون `cfg()` مقادیر sandbox را می‌دهد، خودبه‌خود `true` می‌شود.
### ۲. `GatewayFactory` — استفاده از mellat در sandbox بدون Mock
sandbox یعنی اتصال واقعی به banktest، پس **نباید** Mock برگردد. یک متد `isMellatSandbox()` اضافه کن و در `resolve()` قبل از چک `isTestMode()` لحاظ کن. همچنین برچسب sandbox در `activeGateways()`.
```php
public function isMellatSandbox(): bool
{
return $this->configRepo->get('mellat_sandbox') === '1';
}
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;
}
if (!$this->isEnabled($name)) {
return null;
}
return $this->gateways[$name] ?? null;
}
public function activeGateways(): array
{
if ($this->isMellatSandbox()) {
return [['name' => 'mellat', 'label' => 'بانک ملت (Sandbox)']];
}
if ($this->isTestMode()) {
return [['name' => 'mellat', 'label' => 'بانک ملت (آزمایشی)']];
}
// ... بدون تغییر
}
```
> نکته: برای تست sandbox، `payment_test_mode` باید `0` باشد و `mellat_sandbox` برابر `1`. اگر هر دو `1` باشند، sandbox اولویت دارد (طبق ترتیب بالا).
### ۳. CSP — اجازهٔ فرم به sandbox.banktest.ir
در `SecurityHeadersSubscriber.php` خط `form-action` را گسترش بده:
```php
. "form-action https://*.shaparak.ir https://sandbox.banktest.ir; base-uri 'none'"
```
### ۴. `SiteConfigController` — کلید قابل‌تنظیم
`mellat_sandbox` را به `ALLOWED_KEYS` اضافه کن تا از `/admin/settings` قابل toggle باشد (یا مستقیم در DB ست شود). مقدار: `'1'` روشن / `'0'` یا نبود = خاموش.
### ۵. مستندسازی — `docs/api/payment.md`
یک بخش «حالت Sandbox ملت (موقت)» اضافه کن:
- فلگ `mellat_sandbox=1``payment_test_mode=0`).
- base-URLها و credentials sandbox.
- توضیح اینکه از کانال SOAP `pgwchannel` استفاده می‌شود و همان verify+settle + چک ضد-دستکاری برقرار است.
- هشدار: **موقت**، فقط روی branch جدا، بعد از تست حذف شود.
## تست (e2e روی ddev)
```bash
# روشن‌کردن sandbox، خاموش‌کردن test_mode
ddev exec mysql -e "INSERT INTO site_config (config_key,config_value,updated_at) VALUES ('mellat_sandbox','1',UNIX_TIMESTAMP()) ON DUPLICATE KEY UPDATE config_value='1',updated_at=UNIX_TIMESTAMP(); UPDATE site_config SET config_value='0' WHERE config_key='payment_test_mode';" db
ddev exec php -l src/Payment/Gateway/MellatGateway.php
ddev exec php -l src/Payment/Gateway/GatewayFactory.php
ddev exec php bin/console cache:clear
# ساخت یک Payment pending با gateway=mellat و امتحان initiate (باید RefId واقعی از sandbox بگیرد یا کد خطای معنی‌دار ملت)
# سپس pay → redirect به فرم sandbox → callback → verify+settle
```
معیار موفقیت: `initiate` از banktest یک `0,<RefId>` برمی‌گرداند (نه HTTP 500، نه exception اتصال)، فرم auto-submit به `sandbox.banktest.ir/.../startpay.mellat` باز می‌شود، بعد از پرداخت آزمایشی callback به بک‌اند می‌آید، `verify` با ResCode `0/43/45` موفق می‌شود و `payment.status=success` + `reference_id` ثبت می‌شود.
## نکات مهم
- **برگشت‌پذیری:** کل رفتار sandbox پشت فلگ `mellat_sandbox` است؛ خاموش‌کردنش = رفتار prod. هیچ URL prod حذف نشود.
- **جدا از test_mode:** sandbox اتصال واقعی است؛ نباید `MockGateway` برگردد.
- **credentials در حالت sandbox از config خوانده نشوند** تا مقادیر prod نشتی نکنند (عمدی).
- چک ضد-دستکاری در `PaymentManager` (RefId==gateway_token، SaleOrderId==payment.id) و numeric orderId (`payment.id`) بدون تغییر باقی می‌مانند و با sandbox هم کار می‌کنند.
- `SERVICE_URL` sandbox **بدون** `?wsdl` باشد (POST به `?wsdl` خطای 500 می‌دهد).
- چون موقت است، credentials به‌صورت const در کلاس قرار می‌گیرند (نه `.env`)؛ این عمدی و برای سادگی حذف بعدی است.