- 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.
265 lines
13 KiB
Markdown
265 lines
13 KiB
Markdown
# فعالسازی درگاه 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`)؛ این عمدی و برای سادگی حذف بعدی است.
|