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
+29 -1
View File
@@ -647,6 +647,7 @@ List all payments.
"gateway": "mellat",
"type": "appointment",
"ref_id": "1234567",
"card_pan": "502229******2928",
"patient_mobile": "0912...",
"patient_name": "علی احمدی",
"appointment_uuid": "...",
@@ -656,10 +657,37 @@ List all payments.
}
```
### Errors
> `card_pan` شمارهٔ کارت ماسک‌شدهٔ پرداخت‌کننده (۶ رقم اول + ۴ رقم آخر) است که درگاه در callback برمی‌گرداند (ملت: `CardHolderPan`) و در `metadata.card_pan` پرداخت ذخیره می‌شود؛ اگر درگاه آن را نفرستد `null`. `refunds[]` تاریخچهٔ استردادها (`amount` ریال، `ref` شماره پیگیری، `at` unix).
### POST `/api/v1/admin/payments/{uuid}/refund`
استرداد وجه یک پرداخت **موفق** (کل یا جزئی). فقط `ROLE_ADMIN`. فقط درگاه ملت پشتیبانی می‌شود (سپ خطا می‌دهد).
**Request body:**
| فیلد | نوع | توضیح |
|------|-----|-------|
| `amount` | integer? | مبلغ استرداد به **ریال**. اگر ندهی = کل باقی‌ماندهٔ قابل استرداد. |
استرداد جزئی چندباره مجاز است تا سقف مبلغ خرید. استرداد کامل (رسیدن جمع به مبلغ کل) وضعیت را `refunded` می‌کند.
**Response 200:**
```json
{ "success": true, "data": { "status": "refunded", "refund_ref": "183800538958" } }
```
> کد `0` درگاه ملت فقط «پذیرش اولیهٔ درخواست استرداد» است؛ عودت نهایی به کارت ممکن است چند روز طول بکشد.
### POST `/api/v1/admin/payments/{uuid}/reverse`
برگشت وجه یک پرداخت **موفقِ settle‌نشده** (بدون body). فقط `ROLE_ADMIN`. در موفقیت وضعیت `refunded`.
**Response 200:** `{ "success": true, "data": { "status": "refunded" } }`
### Errors (payment refund/reverse/detail)
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_NOT_FOUND_001` | 404 | پرداخت یافت نشد |
| `ERR_PAYMENT_002` | 422 | مبلغ نامعتبر / پرداخت غیرقابل استرداد / درگاه پشتیبانی نمی‌کند |
---
+18
View File
@@ -62,6 +62,24 @@
- **چک ضد-دستکاری (اجباری مستند):** در callback، `RefId` بازگشتی باید با `gateway_token` ذخیره‌شده و `SaleOrderId` با `payment.id` برابر باشد؛ در غیر این‌صورت تراکنش `failed` می‌شود (این چک برای درگاه‌هایی که این فیلدها را برنمی‌گردانند، مثل سپ، رد می‌شود).
- **دامنهٔ callback/Referer:** ملت `Referer` و `callBackUrl` را با دامنهٔ ثبت‌شدهٔ پذیرنده مقایسه می‌کند؛ در صورت عدم تطابق خطای `62`. مطمئن شوید دامنهٔ بک‌اند = دامنهٔ ثبت‌شده نزد ملت.
**استرداد / برگشت وجه ملت:** درگاه دو متد عودت دارد که از پنل ادمین (`POST /api/v1/admin/payments/{uuid}/refund` و `/reverse`) در دسترس‌اند:
- **Refund (`bpRefundRequest`):** برای تراکنش **settle‌شده**؛ کل یا جزئی (چندباره تا سقف مبلغ خرید). خروجی `0,RefId`؛ کد `0` فقط پذیرش اولیه است. استردادها در `payment.metadata.refunds[]` نگه داشته می‌شوند؛ استرداد کامل → وضعیت `refunded`.
- **Reversal (`bpReversalRequest`):** فقط برای تراکنش **settle‌نشده** (قبل از واریز)؛ کد `0`/`48` موفق. چون جریان ما بلافاصله verify+settle می‌کند، مسیر اصلی Refund است.
- در `MellatGateway`: sandbox=REST (`/ipg2/rest/bpRefundRequest`,`/bpReversalRequest` با Basic Auth)، prod=SOAP. `orderId` هر درخواست یکتای عددی است. `SepGateway`/`MockGateway` هم متدها را دارند (سپ = عدم پشتیبانی، mock = موفق).
- **معکوس‌سازی post-action:** هنگام **استرداد کامل** (یا برگشت وجه)، اثر پرداخت هم برگردانده می‌شود (`runReversePostAction`): نوبت → `cancelled_by_user` (اسلات آزاد می‌شود)؛ اشتراک → حذف `ClinicSubscription` ساخته‌شده از آن پرداخت؛ کیف‌پول پیامک → `deduct` مبلغ شارژ. استرداد **جزئی** post-action را برنمی‌گرداند.
**حالت Sandbox ملت (موقت — banktest.ir):** برای تست بدون درگاه واقعی، فلگ تنظیماتی `mellat_sandbox` وجود دارد (روی branch `feature/mellat-sandbox`؛ موقت).
- روشن‌کردن: `mellat_sandbox=1` **و** `payment_test_mode=0` (sandbox اتصال واقعی است، نه `MockGateway`؛ اگر `payment_test_mode=1` هم باشد sandbox اولویت دارد). خاموش (نبود/`0`) = رفتار prod.
- **پروتکل sandbox = REST** (نه SOAP): SOAP آزمایشیِ banktest روی `pgwchannel` خطای `502` می‌دهد؛ فقط REST (`.../ipg2/rest/…`) سالم است. پس در حالت sandbox، `MellatGateway`:
- `initiate``POST .../ipg2/rest/bpPayRequest` با JSON + هدر `Authorization: Basic base64(userName:userPassword)`؛ پاسخ رشتهٔ `0,RefId`.
- فرم پرداخت → `https://sandbox.banktest.ir/mellat/bpm.shaparak.ir/pgwchannel/startpay.mellat` (POST `RefId`). توجه: API روی `ipg2/rest` است ولی صفحهٔ فرم فقط روی `pgwchannel/startpay.mellat` سالم است (`ipg2/startpay.mellat` روی banktest خطای `404` می‌دهد).
- `verify` → sandbox متد ترکیبی `bpVerifySettleRequest` را پشتیبانی نمی‌کند (کد `44`)؛ پس دو فراخوانی جدا: `POST .../ipg2/rest/bpVerifyRequest` (قبول `0`/`43`) سپس `POST .../ipg2/rest/bpSettleRequest` (قبول `0`/`45`). در prod همان `bpVerifySettleRequest` ترکیبی (SOAP) استفاده می‌شود.
- callback در sandbox از IP خارج از رنج شاپرک می‌آید؛ کنترلر برای `gateway=mellat` + `mellat_sandbox` چک IP را رد می‌کند (مثل `payment_test_mode`).
- credentials sandbox در خودِ `MellatGateway` به‌صورت const است (terminalId `134759344` / user `user134759344`)؛ در حالت sandbox عمداً از `site_config` خوانده نمی‌شود تا credentials واقعیِ prod نشتی نکند. کل رفتار prod (SOAP روی `bpm.shaparak.ir`) دست‌نخورده باقی می‌ماند.
- CSP صفحات پرداخت `form-action` را علاوه بر `*.shaparak.ir` به `sandbox.banktest.ir` هم می‌دهد.
- **موقتی:** بعد از اتمام تست، این branch/فلگ باید حذف شود.
- **محدودیت sandbox:** banktest عملیات **استرداد/برگشت وجه** را شبیه‌سازی نمی‌کند؛ `bpRefundRequest` همیشه کد `34` (خطای سیستمی) می‌دهد. کدهای پاسخ ملت با `mellatMessage()` به پیام فارسی نگاشت می‌شوند. استرداد واقعی فقط در prod (SOAP) کار می‌کند.
---
## GET `/api/v1/payment/config`