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:
@@ -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).
|
||||
@@ -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`)؛ این عمدی و برای سادگی حذف بعدی است.
|
||||
@@ -1,13 +1,15 @@
|
||||
import React from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowRightIcon } from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import type { Payment } from '../types';
|
||||
import { formatDate, formatDateTime, formatRial } from '../lib/utils';
|
||||
import { formatDateTime, formatRial } from '../lib/utils';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import StatusBadge from '../components/ui/StatusBadge';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
|
||||
const PAYMENT_TYPE_LABELS: Record<string, string> = {
|
||||
appointment: 'نوبت',
|
||||
@@ -35,6 +37,24 @@ export default function PaymentDetailPage() {
|
||||
});
|
||||
|
||||
const payment = data?.data;
|
||||
const queryClient = useQueryClient();
|
||||
const [confirm, setConfirm] = React.useState<null | 'refund' | 'reverse'>(null);
|
||||
|
||||
const action = useMutation({
|
||||
mutationFn: (kind: 'refund' | 'reverse') =>
|
||||
api.post<ApiResponse<{ status: string }>>(`/api/v1/admin/payments/${uuid}/${kind}`, {}),
|
||||
onSuccess: (_res, kind) => {
|
||||
toast.success(kind === 'refund' ? 'درخواست استرداد وجه ثبت شد' : 'برگشت وجه انجام شد');
|
||||
queryClient.invalidateQueries({ queryKey: ['payment', uuid] });
|
||||
setConfirm(null);
|
||||
},
|
||||
onError: (e: any) => {
|
||||
toast.error(e?.message || 'عملیات ناموفق بود');
|
||||
setConfirm(null);
|
||||
},
|
||||
});
|
||||
|
||||
const canRefund = payment?.status === 'success' && payment?.gateway === 'mellat';
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -72,6 +92,7 @@ export default function PaymentDetailPage() {
|
||||
<InfoRow label="وضعیت" value={<StatusBadge type="payment" value={payment.status} />} />
|
||||
<InfoRow label="درگاه" value={<span className="uppercase">{payment.gateway}</span>} />
|
||||
<InfoRow label="شماره مرجع" value={payment.ref_id ? <span dir="ltr" className="font-mono text-xs">{payment.ref_id}</span> : null} />
|
||||
<InfoRow label="شماره کارت" value={payment.card_pan ? <span dir="ltr" className="font-mono text-xs">{payment.card_pan}</span> : null} />
|
||||
<InfoRow label="تاریخ پرداخت" value={formatDateTime(payment.paid_at)} />
|
||||
<InfoRow label="تاریخ ثبت" value={formatDateTime(payment.created_at)} />
|
||||
{payment.appointment_uuid && (
|
||||
@@ -86,6 +107,50 @@ export default function PaymentDetailPage() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{payment.refunds && payment.refunds.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6 mt-4">
|
||||
<h3 className="text-sm font-semibold mb-3">استردادها</h3>
|
||||
{payment.refunds.map((r, i) => (
|
||||
<InfoRow
|
||||
key={i}
|
||||
label={formatDateTime(new Date(r.at * 1000).toISOString())}
|
||||
value={
|
||||
<span>
|
||||
{formatRial(r.amount)}
|
||||
{r.ref && <span dir="ltr" className="font-mono text-xs text-gray-400"> ({r.ref})</span>}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canRefund && (
|
||||
<div className="flex gap-3 mt-4">
|
||||
<button onClick={() => setConfirm('refund')} className="btn danger">
|
||||
استرداد وجه
|
||||
</button>
|
||||
<button onClick={() => setConfirm('reverse')} className="btn">
|
||||
برگشت وجه
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirm !== null}
|
||||
danger
|
||||
loading={action.isPending}
|
||||
title={confirm === 'reverse' ? 'برگشت وجه' : 'استرداد وجه'}
|
||||
message={
|
||||
confirm === 'reverse'
|
||||
? 'کل مبلغ این تراکنش برگشت داده میشود. ادامه میدهید؟'
|
||||
: 'درخواست استرداد کل مبلغ به کارت پرداختکننده ثبت میشود (عودت نهایی ممکن است چند روز طول بکشد). ادامه میدهید؟'
|
||||
}
|
||||
confirmLabel={confirm === 'reverse' ? 'برگشت وجه' : 'استرداد وجه'}
|
||||
onConfirm={() => confirm && action.mutate(confirm)}
|
||||
onCancel={() => setConfirm(null)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 p-16 text-center text-gray-400">
|
||||
|
||||
@@ -118,6 +118,8 @@ export interface Payment {
|
||||
status: PaymentStatus;
|
||||
gateway: PaymentGateway;
|
||||
ref_id: string | null;
|
||||
card_pan?: string | null;
|
||||
refunds?: { amount: number; ref: string; at: number }[];
|
||||
patient_mobile: string;
|
||||
appointment_uuid: string | null;
|
||||
paid_at: string | null;
|
||||
|
||||
+29
-1
@@ -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 | مبلغ نامعتبر / پرداخت غیرقابل استرداد / درگاه پشتیبانی نمیکند |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -37,6 +37,8 @@ class AdminApiController extends BaseController
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly \App\Appointment\Service\SlotCalculatorService $slotCalculator,
|
||||
private readonly \App\Insurance\Service\TenantInsuranceCleanupService $insuranceCleanup,
|
||||
private readonly \App\Payment\Service\PaymentManager $paymentManager,
|
||||
private readonly \App\Payment\Repository\PaymentRepository $paymentRepo,
|
||||
) {}
|
||||
|
||||
// ── Users ─────────────────────────────────────────────────────────────────
|
||||
@@ -891,7 +893,7 @@ class AdminApiController extends BaseController
|
||||
{
|
||||
$rows = $this->em->createQueryBuilder()
|
||||
->select(
|
||||
'p.uuid, p.orderId, p.amountRials, p.status, p.gateway, p.type, p.referenceId, p.createdAt, p.updatedAt',
|
||||
'p.uuid, p.orderId, p.amountRials, p.status, p.gateway, p.type, p.referenceId, p.metadata, p.createdAt, p.updatedAt',
|
||||
'u.mobileNumber as patient_mobile, u.realName as patient_name',
|
||||
'a.uuid as appointment_uuid',
|
||||
)
|
||||
@@ -914,6 +916,8 @@ class AdminApiController extends BaseController
|
||||
'gateway' => $p['gateway'],
|
||||
'type' => $p['type'],
|
||||
'ref_id' => $p['referenceId'],
|
||||
'card_pan' => $p['metadata']['card_pan'] ?? null,
|
||||
'refunds' => $p['metadata']['refunds'] ?? [],
|
||||
'patient_mobile' => $p['patient_mobile'],
|
||||
'patient_name' => $p['patient_name'],
|
||||
'appointment_uuid' => $p['appointment_uuid'],
|
||||
@@ -922,6 +926,39 @@ class AdminApiController extends BaseController
|
||||
]);
|
||||
}
|
||||
|
||||
#[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_002, $result->errorMessage, 422);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/payments/{uuid}/reverse', methods: ['POST'])]
|
||||
public function reversePayment(string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$payment = $this->paymentRepo->findOneBy(['uuid' => $uuid]);
|
||||
if ($payment === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پرداخت یافت نشد', 404);
|
||||
}
|
||||
$result = $this->paymentManager->reversePayment($payment, $request->getClientIp() ?? '');
|
||||
|
||||
return $result->success
|
||||
? $this->success(['status' => $payment->getStatus()])
|
||||
: $this->error(ErrorCodes::ERR_PAYMENT_002, $result->errorMessage, 422);
|
||||
}
|
||||
|
||||
// ── Representations ───────────────────────────────────────────────────────
|
||||
|
||||
#[OA\Get(
|
||||
|
||||
@@ -34,6 +34,7 @@ class SiteConfigController extends BaseController
|
||||
'payment_test_mode',
|
||||
'payment_allowed_frontend_hosts',
|
||||
'mellat_enabled',
|
||||
'mellat_sandbox',
|
||||
'mellat_terminal_id',
|
||||
'mellat_username',
|
||||
'mellat_password',
|
||||
|
||||
@@ -285,8 +285,11 @@ class PaymentController extends BaseController
|
||||
public function callback(string $gateway, Request $request): \Symfony\Component\HttpFoundation\Response
|
||||
{
|
||||
$clientIp = $request->getClientIp() ?? '';
|
||||
if (!$this->gateways->isTestMode() && !$this->isAllowedCallbackIp($clientIp)) {
|
||||
return new JsonResponse(['success' => false, 'message' => 'دسترسی ممنوع'], 403);
|
||||
// در حالت تست یا sandbox ملت، callback از IPی خارج از رنج شاپرک میآید؛ IP-check رد میشود.
|
||||
$bypassIp = $this->gateways->isTestMode()
|
||||
|| ($gateway === 'mellat' && $this->gateways->isMellatSandbox());
|
||||
if (!$bypassIp && !$this->isAllowedCallbackIp($clientIp)) {
|
||||
return $this->renderPaymentResult('forbidden');
|
||||
}
|
||||
|
||||
$callbackData = array_merge($request->query->all(), $request->request->all());
|
||||
@@ -551,6 +554,7 @@ class PaymentController extends BaseController
|
||||
'invalid' => ['قابل پرداخت نیست', 'این سفارش در وضعیت قابل پرداخت نیست.'],
|
||||
'gateway' => ['درگاه نامعتبر', 'درگاه پرداخت انتخابی نامعتبر یا غیرفعال است.'],
|
||||
'invalid_return' => ['آدرس بازگشت نامعتبر', 'آدرس بازگشت مجاز نیست.'],
|
||||
'forbidden' => ['دسترسی غیرمجاز', 'این درخواست از مبدأ مجاز ارسال نشده است.'],
|
||||
];
|
||||
[$title, $message] = $labels[$status] ?? ['خطا در پرداخت', 'خطایی در فرآیند پرداخت رخ داد.'];
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ class PaymentLog
|
||||
{
|
||||
public const ACTION_INITIATE = 'initiate';
|
||||
public const ACTION_VERIFY = 'verify';
|
||||
public const ACTION_REFUND = 'refund';
|
||||
public const ACTION_REVERSE = 'reverse';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
|
||||
@@ -39,12 +39,22 @@ class GatewayFactory
|
||||
return $this->configRepo->get('payment_test_mode') === '1';
|
||||
}
|
||||
|
||||
/** حالت sandbox ملت (banktest.ir) — اتصال واقعی، جدا از test_mode/Mock. موقت. */
|
||||
public function isMellatSandbox(): bool
|
||||
{
|
||||
return $this->configRepo->get('mellat_sandbox') === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* درگاهِ قابلاستفاده برای این نام؛ در حالت تست همیشه Mock، در غیر اینصورت
|
||||
* درگاه واقعی در صورت فعال بودن. null یعنی نامعتبر/غیرفعال.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
@@ -69,6 +79,9 @@ class GatewayFactory
|
||||
*/
|
||||
public function activeGateways(): array
|
||||
{
|
||||
if ($this->isMellatSandbox()) {
|
||||
return [['name' => 'mellat', 'label' => 'بانک ملت (Sandbox)']];
|
||||
}
|
||||
if ($this->isTestMode()) {
|
||||
return [['name' => 'mellat', 'label' => 'بانک ملت (آزمایشی)']];
|
||||
}
|
||||
|
||||
@@ -12,6 +12,16 @@ class MellatGateway implements PaymentGatewayInterface
|
||||
// endpoint سرویس SOAP (بدون ?wsdl؛ ?wsdl فقط توصیفِ سرویس است و POST به آن 500 میدهد).
|
||||
private const SERVICE_URL = 'https://bpm.shaparak.ir/pgwchannel/services/pgw';
|
||||
|
||||
// sandbox banktest.ir (موقت). پشت فلگ mellat_sandbox.
|
||||
// نکته: SOAP sandbox (pgwchannel) روی banktest 502 میدهد؛ فقط REST (ipg2) سالم است،
|
||||
// پس در حالت sandbox از REST (JSON + Basic Auth) استفاده میشود، نه SOAP.
|
||||
// نکته: API روی REST (ipg2) است ولی فرمِ پرداخت فقط روی pgwchannel/startpay.mellat سالم است (ipg2/startpay = 404).
|
||||
private const SANDBOX_REST_BASE = 'https://sandbox.banktest.ir/mellat/bpm.shaparak.ir/ipg2/rest';
|
||||
private const SANDBOX_PAYMENT_URL = 'https://sandbox.banktest.ir/mellat/bpm.shaparak.ir/pgwchannel/startpay.mellat';
|
||||
private const SANDBOX_TERMINAL_ID = '134759344';
|
||||
private const SANDBOX_USERNAME = 'user134759344';
|
||||
private const SANDBOX_PASSWORD = '17384843';
|
||||
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
private readonly SiteConfigRepository $configRepo,
|
||||
@@ -33,32 +43,97 @@ class MellatGateway implements PaymentGatewayInterface
|
||||
&& $this->cfg('mellat_password', $this->password) !== '';
|
||||
}
|
||||
|
||||
private function sandbox(): bool
|
||||
{
|
||||
return $this->configRepo->get('mellat_sandbox') === '1';
|
||||
}
|
||||
|
||||
private function paymentUrl(): string
|
||||
{
|
||||
return $this->sandbox() ? self::SANDBOX_PAYMENT_URL : self::PAYMENT_URL;
|
||||
}
|
||||
|
||||
/** هدر Basic Auth برای REST sandbox (base64 از userName:userPassword). */
|
||||
private function restHeaders(): array
|
||||
{
|
||||
$auth = base64_encode(self::SANDBOX_USERNAME . ':' . self::SANDBOX_PASSWORD);
|
||||
return ['Content-Type' => 'application/json', 'Authorization' => 'Basic ' . $auth];
|
||||
}
|
||||
|
||||
/** پاسخ REST یک رشتهٔ ساده مثل `0,RefId` یا `0` است (گاهی داخل "…"). */
|
||||
private function restParts(string $body): array
|
||||
{
|
||||
$body = trim($body, " \t\n\r\0\x0B\"");
|
||||
return array_map('trim', explode(',', $body));
|
||||
}
|
||||
|
||||
/** یک فراخوانی REST sandbox؛ فقط کد پاسخ (بخش اول) را برمیگرداند. */
|
||||
private function restCall(string $path, array $json): string
|
||||
{
|
||||
$body = $this->httpClient->request('POST', self::SANDBOX_REST_BASE . $path, [
|
||||
'json' => $json,
|
||||
'headers' => $this->restHeaders(),
|
||||
'timeout' => 10,
|
||||
])->getContent();
|
||||
|
||||
return $this->restParts($body)[0] ?? '-1';
|
||||
}
|
||||
|
||||
private function cfg(string $key, ?string $envFallback): string
|
||||
{
|
||||
// در sandbox، credentials از config خوانده نمیشوند تا مقادیر واقعیِ prod نشتی نکنند.
|
||||
if ($this->sandbox()) {
|
||||
return match ($key) {
|
||||
'mellat_terminal_id' => self::SANDBOX_TERMINAL_ID,
|
||||
'mellat_username' => self::SANDBOX_USERNAME,
|
||||
'mellat_password' => self::SANDBOX_PASSWORD,
|
||||
default => $envFallback ?? '',
|
||||
};
|
||||
}
|
||||
return (string) ($this->configRepo->get($key) ?: $envFallback ?? '');
|
||||
}
|
||||
|
||||
public function initiate(int $amountRials, string $orderId, string $callbackUrl): PaymentInitResult
|
||||
{
|
||||
try {
|
||||
$response = $this->httpClient->request(
|
||||
'POST',
|
||||
self::SERVICE_URL,
|
||||
[
|
||||
'body' => $this->buildRequestPayload($amountRials, $orderId, $callbackUrl),
|
||||
'headers' => ['Content-Type' => 'text/xml; charset=utf-8', 'SOAPAction' => '""'],
|
||||
'timeout' => 10,
|
||||
]
|
||||
);
|
||||
|
||||
$resCode = $this->parseResCode($response->getContent());
|
||||
if ($this->sandbox()) {
|
||||
[$resCode, $refId] = $this->restParts(
|
||||
$this->httpClient->request('POST', self::SANDBOX_REST_BASE . '/bpPayRequest', [
|
||||
'json' => [
|
||||
'terminalId' => (int) self::SANDBOX_TERMINAL_ID,
|
||||
'userName' => self::SANDBOX_USERNAME,
|
||||
'userPassword' => self::SANDBOX_PASSWORD,
|
||||
'orderId' => (int) $orderId,
|
||||
'amount' => $amountRials,
|
||||
'localDate' => $this->date(),
|
||||
'localTime' => $this->time(),
|
||||
'additionalData' => '',
|
||||
'callBackUrl' => $callbackUrl,
|
||||
'payerId' => '0',
|
||||
],
|
||||
'headers' => $this->restHeaders(),
|
||||
'timeout' => 10,
|
||||
])->getContent()
|
||||
) + ['-1', ''];
|
||||
} else {
|
||||
$response = $this->httpClient->request(
|
||||
'POST',
|
||||
self::SERVICE_URL,
|
||||
[
|
||||
'body' => $this->buildRequestPayload($amountRials, $orderId, $callbackUrl),
|
||||
'headers' => ['Content-Type' => 'text/xml; charset=utf-8', 'SOAPAction' => '""'],
|
||||
'timeout' => 10,
|
||||
]
|
||||
);
|
||||
$resCode = $this->parseResCode($response->getContent());
|
||||
$refId = $this->parseRefId($response->getContent());
|
||||
}
|
||||
|
||||
if ($resCode !== '0') {
|
||||
return new PaymentInitResult(false, errorMessage: "Mellat error: $resCode");
|
||||
}
|
||||
|
||||
$refId = $this->parseRefId($response->getContent());
|
||||
$redirectUrl = self::PAYMENT_URL . '?RefId=' . $refId;
|
||||
$redirectUrl = $this->paymentUrl() . '?RefId=' . $refId;
|
||||
|
||||
// درگاه ملت باید با POST فرم (فیلد RefId) باز شود؛ redirectUrl (شامل RefId)
|
||||
// برای سازگاری با مصرفکنندههای قدیمی نگه داشته میشود.
|
||||
@@ -96,6 +171,28 @@ class MellatGateway implements PaymentGatewayInterface
|
||||
}
|
||||
|
||||
try {
|
||||
if ($this->sandbox()) {
|
||||
// sandbox banktest متد ترکیبی bpVerifySettleRequest را پشتیبانی نمیکند (کد 44)؛
|
||||
// پس verify و settle جدا صدا زده میشوند. 0=موفق، 43=قبلاً verify، 45=قبلاً settle.
|
||||
$payload = [
|
||||
'terminalId' => (int) self::SANDBOX_TERMINAL_ID,
|
||||
'userName' => self::SANDBOX_USERNAME,
|
||||
'userPassword' => self::SANDBOX_PASSWORD,
|
||||
'orderId' => (int) $saleOrderId,
|
||||
'saleOrderId' => (int) $saleOrderId,
|
||||
'saleReferenceId' => (int) $saleReferenceId,
|
||||
];
|
||||
$vc = $this->restCall('/bpVerifyRequest', $payload);
|
||||
if (!in_array($vc, ['0', '43'], true)) {
|
||||
return new PaymentVerifyResult(false, errorMessage: "Verify failed: $vc");
|
||||
}
|
||||
$sc = $this->restCall('/bpSettleRequest', $payload);
|
||||
if (!in_array($sc, ['0', '45'], true)) {
|
||||
return new PaymentVerifyResult(false, errorMessage: "Settle failed: $sc");
|
||||
}
|
||||
return new PaymentVerifyResult(true, referenceId: $saleReferenceId);
|
||||
}
|
||||
|
||||
$response = $this->httpClient->request(
|
||||
'POST',
|
||||
self::SERVICE_URL,
|
||||
@@ -105,7 +202,6 @@ class MellatGateway implements PaymentGatewayInterface
|
||||
'timeout' => 10,
|
||||
]
|
||||
);
|
||||
|
||||
$verifyCode = $this->parseResCode($response->getContent());
|
||||
// 0 = موفق، 43 = پیشتر verify شده، 45 = پیشتر settle شده (هر دو idempotent = موفق).
|
||||
if (!in_array($verifyCode, ['0', '43', '45'], true)) {
|
||||
@@ -168,6 +264,150 @@ XML;
|
||||
XML;
|
||||
}
|
||||
|
||||
public function refund(string $saleOrderId, string $saleReferenceId, int $refundAmountRials): PaymentRefundResult
|
||||
{
|
||||
try {
|
||||
if ($this->sandbox()) {
|
||||
$parts = $this->restParts(
|
||||
$this->httpClient->request('POST', self::SANDBOX_REST_BASE . '/bpRefundRequest', [
|
||||
'json' => $this->refundPayload($saleOrderId, $saleReferenceId, $refundAmountRials),
|
||||
'headers' => $this->restHeaders(),
|
||||
'timeout' => 10,
|
||||
])->getContent()
|
||||
);
|
||||
} else {
|
||||
$xml = $this->httpClient->request('POST', self::SERVICE_URL, [
|
||||
'body' => $this->buildRefundPayload($saleOrderId, $saleReferenceId, $refundAmountRials),
|
||||
'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: $this->mellatMessage($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());
|
||||
}
|
||||
}
|
||||
|
||||
public function reverse(string $saleOrderId, string $saleReferenceId): PaymentRefundResult
|
||||
{
|
||||
try {
|
||||
if ($this->sandbox()) {
|
||||
$code = $this->restCall('/bpReversalRequest', $this->reversalPayload($saleOrderId, $saleReferenceId));
|
||||
} else {
|
||||
$xml = $this->httpClient->request('POST', self::SERVICE_URL, [
|
||||
'body' => $this->buildReversalPayload($saleOrderId, $saleReferenceId),
|
||||
'headers' => ['Content-Type' => 'text/xml; charset=utf-8', 'SOAPAction' => '""'],
|
||||
'timeout' => 10,
|
||||
])->getContent();
|
||||
$code = $this->parseResCode($xml);
|
||||
}
|
||||
// 0 = موفق، 48 = پیشتر reverse شده (idempotent = موفق).
|
||||
if (!in_array($code, ['0', '48'], true)) {
|
||||
return new PaymentRefundResult(false, errorMessage: $this->mellatMessage($code));
|
||||
}
|
||||
return new PaymentRefundResult(true);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Payment reversal failed (mellat): ' . $e->getMessage(), ['saleReferenceId' => $saleReferenceId]);
|
||||
return new PaymentRefundResult(false, errorMessage: $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** نگاشت کد پاسخ ملت به پیام فارسی (جدول مستند ۱.۳۸). */
|
||||
private function mellatMessage(string $code): string
|
||||
{
|
||||
$map = [
|
||||
'11' => 'شماره کارت نامعتبر است',
|
||||
'12' => 'موجودی کافی نیست',
|
||||
'17' => 'کاربر از انجام تراکنش منصرف شده است',
|
||||
'19' => 'مبلغ استرداد بیش از مبلغ تراکنش خرید است',
|
||||
'21' => 'پذیرنده نامعتبر است',
|
||||
'24' => 'اطلاعات کاربری پذیرنده نامعتبر است',
|
||||
'25' => 'مبلغ نامعتبر است',
|
||||
'34' => 'خطای سیستمی درگاه (در محیط تست، استرداد پشتیبانی نمیشود)',
|
||||
'42' => 'تراکنش خرید (Sale) یافت نشد',
|
||||
'43' => 'این تراکنش پیشتر تأیید شده است',
|
||||
'44' => 'درخواست تأیید یافت نشد',
|
||||
'45' => 'این تراکنش پیشتر واریز (Settle) شده است',
|
||||
'46' => 'تراکنش واریز (Settle) نشده است',
|
||||
'47' => 'تراکنش واریز یافت نشد',
|
||||
'48' => 'این تراکنش پیشتر برگشت (Reverse) شده است',
|
||||
'51' => 'تراکنش تکراری است',
|
||||
'61' => 'خطا در واریز',
|
||||
'62' => 'مسیر بازگشت در دامنهٔ ثبتشدهٔ پذیرنده نیست',
|
||||
];
|
||||
return ($map[$code] ?? 'خطای درگاه') . " (کد $code)";
|
||||
}
|
||||
|
||||
/** orderId یکتای عددی برای هر درخواست refund/reverse (مستند: هر بار باید یکتا باشد). */
|
||||
private function uniqueOrderId(): int
|
||||
{
|
||||
return (int) substr((string) (int) (microtime(true) * 1000), -12);
|
||||
}
|
||||
|
||||
/** بدنهٔ JSON مشترک refund (REST). */
|
||||
private function refundPayload(string $saleOrderId, string $saleReferenceId, int $refundAmountRials): array
|
||||
{
|
||||
return $this->reversalPayload($saleOrderId, $saleReferenceId) + ['refundAmount' => $refundAmountRials];
|
||||
}
|
||||
|
||||
/** بدنهٔ JSON مشترک reverse (REST). */
|
||||
private function reversalPayload(string $saleOrderId, string $saleReferenceId): array
|
||||
{
|
||||
return [
|
||||
'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' => (int) $saleOrderId,
|
||||
'saleReferenceId' => (int) $saleReferenceId,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildRefundPayload(string $saleOrderId, string $saleReferenceId, int $refundAmountRials): string
|
||||
{
|
||||
$p = $this->reversalPayload($saleOrderId, $saleReferenceId);
|
||||
return <<<XML
|
||||
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:int="http://interfaces.core.sw.bsl.com/westernmellat">
|
||||
<soapenv:Body>
|
||||
<int:bpRefundRequest>
|
||||
<terminalId>{$p['terminalId']}</terminalId>
|
||||
<userName>{$p['userName']}</userName>
|
||||
<userPassword>{$p['userPassword']}</userPassword>
|
||||
<orderId>{$p['orderId']}</orderId>
|
||||
<saleOrderId>{$p['saleOrderId']}</saleOrderId>
|
||||
<saleReferenceId>{$p['saleReferenceId']}</saleReferenceId>
|
||||
<refundAmount>{$refundAmountRials}</refundAmount>
|
||||
</int:bpRefundRequest>
|
||||
</soapenv:Body>
|
||||
</soapenv:Envelope>
|
||||
XML;
|
||||
}
|
||||
|
||||
private function buildReversalPayload(string $saleOrderId, string $saleReferenceId): string
|
||||
{
|
||||
$p = $this->reversalPayload($saleOrderId, $saleReferenceId);
|
||||
return <<<XML
|
||||
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:int="http://interfaces.core.sw.bsl.com/westernmellat">
|
||||
<soapenv:Body>
|
||||
<int:bpReversalRequest>
|
||||
<terminalId>{$p['terminalId']}</terminalId>
|
||||
<userName>{$p['userName']}</userName>
|
||||
<userPassword>{$p['userPassword']}</userPassword>
|
||||
<orderId>{$p['orderId']}</orderId>
|
||||
<saleOrderId>{$p['saleOrderId']}</saleOrderId>
|
||||
<saleReferenceId>{$p['saleReferenceId']}</saleReferenceId>
|
||||
</int:bpReversalRequest>
|
||||
</soapenv:Body>
|
||||
</soapenv:Envelope>
|
||||
XML;
|
||||
}
|
||||
|
||||
private function parseResCode(string $xml): string
|
||||
{
|
||||
preg_match('/<return>(.*?)<\/return>/', $xml, $m);
|
||||
|
||||
@@ -35,4 +35,14 @@ class MockGateway implements PaymentGatewayInterface
|
||||
|
||||
return new PaymentVerifyResult(true, referenceId: $refId, amountRials: $amount);
|
||||
}
|
||||
|
||||
public function refund(string $saleOrderId, string $saleReferenceId, int $refundAmountRials): PaymentRefundResult
|
||||
{
|
||||
return new PaymentRefundResult(true, refundRefId: 'MOCK-REFUND-' . $saleOrderId);
|
||||
}
|
||||
|
||||
public function reverse(string $saleOrderId, string $saleReferenceId): PaymentRefundResult
|
||||
{
|
||||
return new PaymentRefundResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,4 +20,14 @@ interface PaymentGatewayInterface
|
||||
* Verifies callback and confirms payment.
|
||||
*/
|
||||
public function verify(array $callbackData): PaymentVerifyResult;
|
||||
|
||||
/**
|
||||
* Refunds a settled transaction (full or partial). Amounts in Rials.
|
||||
*/
|
||||
public function refund(string $saleOrderId, string $saleReferenceId, int $refundAmountRials): PaymentRefundResult;
|
||||
|
||||
/**
|
||||
* Reverses a not-yet-settled transaction.
|
||||
*/
|
||||
public function reverse(string $saleOrderId, string $saleReferenceId): PaymentRefundResult;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Payment\Gateway;
|
||||
|
||||
final class PaymentRefundResult
|
||||
{
|
||||
public function __construct(
|
||||
public readonly bool $success,
|
||||
public readonly string $refundRefId = '', // شماره پیگیری استرداد (refund؛ reversal خالی)
|
||||
public readonly string $errorMessage = '',
|
||||
) {}
|
||||
}
|
||||
@@ -99,4 +99,14 @@ class SepGateway implements PaymentGatewayInterface
|
||||
return new PaymentVerifyResult(false, errorMessage: $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function refund(string $saleOrderId, string $saleReferenceId, int $refundAmountRials): PaymentRefundResult
|
||||
{
|
||||
return new PaymentRefundResult(false, errorMessage: 'استرداد برای این درگاه پشتیبانی نمیشود');
|
||||
}
|
||||
|
||||
public function reverse(string $saleOrderId, string $saleReferenceId): PaymentRefundResult
|
||||
{
|
||||
return new PaymentRefundResult(false, errorMessage: 'برگشت وجه برای این درگاه پشتیبانی نمیشود');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Payment\Entity\Payment;
|
||||
use App\Payment\Entity\PaymentLog;
|
||||
use App\Payment\Gateway\GatewayFactory;
|
||||
use App\Payment\Gateway\PaymentInitResult;
|
||||
use App\Payment\Gateway\PaymentRefundResult;
|
||||
use App\Payment\Repository\PaymentLogRepository;
|
||||
use App\Payment\Repository\PaymentRepository;
|
||||
use App\Representation\Service\JalaliDateService;
|
||||
@@ -155,6 +156,11 @@ final class PaymentManager
|
||||
|
||||
$payment->setStatus(Payment::STATUS_SUCCESS);
|
||||
$payment->setReferenceId($result->referenceId);
|
||||
// شمارهٔ کارت ماسکشدهٔ پرداختکننده (ملت: CardHolderPan) برای نمایش در پنل.
|
||||
$cardPan = $callbackData['CardHolderPan'] ?? $callbackData['SecurePan'] ?? null;
|
||||
if ($cardPan) {
|
||||
$payment->setMetadata(($payment->getMetadata() ?? []) + ['card_pan' => (string) $cardPan]);
|
||||
}
|
||||
$this->em->persist($payment);
|
||||
|
||||
$this->runPostAction($payment);
|
||||
@@ -164,6 +170,76 @@ final class PaymentManager
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* استرداد وجه (کل یا جزئی) یک پرداخت موفق. داخل transaction + قفل ردیف.
|
||||
* $amountRials = null → کل مبلغ. استرداد کامل → وضعیت refunded.
|
||||
*/
|
||||
public function refundPayment(Payment $payment, ?int $amountRials, string $clientIp): PaymentRefundResult
|
||||
{
|
||||
return $this->em->wrapInTransaction(function () use ($payment, $amountRials, $clientIp): PaymentRefundResult {
|
||||
$locked = $this->paymentRepo->findByOrderIdForUpdate($payment->getOrderId());
|
||||
if ($locked === null) {
|
||||
return new PaymentRefundResult(false, errorMessage: 'پرداخت یافت نشد');
|
||||
}
|
||||
if ($locked->getStatus() !== Payment::STATUS_SUCCESS) {
|
||||
return new PaymentRefundResult(false, errorMessage: 'فقط پرداخت موفق قابل استرداد است');
|
||||
}
|
||||
|
||||
$total = $locked->getAmountRials();
|
||||
$meta = $locked->getMetadata() ?? [];
|
||||
$refunded = array_sum(array_column($meta['refunds'] ?? [], 'amount'));
|
||||
$amount = $amountRials ?? ($total - $refunded); // null = باقیماندهٔ قابل استرداد
|
||||
if ($amount <= 0 || $refunded + $amount > $total) {
|
||||
return new PaymentRefundResult(false, errorMessage: 'مبلغ استرداد نامعتبر است');
|
||||
}
|
||||
|
||||
$gateway = $this->gateways->resolve($locked->getGateway());
|
||||
$result = $gateway?->refund((string) $locked->getId(), (string) $locked->getReferenceId(), $amount)
|
||||
?? new PaymentRefundResult(false, errorMessage: 'درگاه نامعتبر است');
|
||||
|
||||
if ($result->success) {
|
||||
$meta['refunds'][] = ['amount' => $amount, 'ref' => $result->refundRefId, 'at' => time()];
|
||||
$locked->setMetadata($meta);
|
||||
// فقط استرداد کامل، post-action (نوبت/اشتراک/کیفپول) را معکوس میکند.
|
||||
if ($refunded + $amount >= $total) {
|
||||
$locked->setStatus(Payment::STATUS_REFUNDED);
|
||||
$this->runReversePostAction($locked);
|
||||
}
|
||||
$this->em->persist($locked);
|
||||
}
|
||||
$this->log($locked, PaymentLog::ACTION_REFUND, $result->success ? 'success' : 'failed',
|
||||
$result->refundRefId ?: null, $clientIp, ['amount' => $amount, 'error' => $result->errorMessage]);
|
||||
return $result;
|
||||
});
|
||||
}
|
||||
|
||||
/** برگشت وجه یک پرداخت settleنشده. در موفقیت وضعیت refunded. داخل transaction + قفل. */
|
||||
public function reversePayment(Payment $payment, string $clientIp): PaymentRefundResult
|
||||
{
|
||||
return $this->em->wrapInTransaction(function () use ($payment, $clientIp): PaymentRefundResult {
|
||||
$locked = $this->paymentRepo->findByOrderIdForUpdate($payment->getOrderId());
|
||||
if ($locked === null) {
|
||||
return new PaymentRefundResult(false, errorMessage: 'پرداخت یافت نشد');
|
||||
}
|
||||
if ($locked->getStatus() !== Payment::STATUS_SUCCESS) {
|
||||
return new PaymentRefundResult(false, errorMessage: 'فقط پرداخت موفق قابل برگشت است');
|
||||
}
|
||||
|
||||
$gateway = $this->gateways->resolve($locked->getGateway());
|
||||
$result = $gateway?->reverse((string) $locked->getId(), (string) $locked->getReferenceId())
|
||||
?? new PaymentRefundResult(false, errorMessage: 'درگاه نامعتبر است');
|
||||
|
||||
if ($result->success) {
|
||||
$locked->setStatus(Payment::STATUS_REFUNDED);
|
||||
$this->runReversePostAction($locked);
|
||||
$this->em->persist($locked);
|
||||
}
|
||||
$this->log($locked, PaymentLog::ACTION_REVERSE, $result->success ? 'success' : 'failed',
|
||||
null, $clientIp, ['error' => $result->errorMessage]);
|
||||
return $result;
|
||||
});
|
||||
}
|
||||
|
||||
public function callbackUrl(Payment $payment): string
|
||||
{
|
||||
$prefix = $payment->getType() === Payment::TYPE_SUBSCRIPTION
|
||||
@@ -184,6 +260,40 @@ final class PaymentManager
|
||||
};
|
||||
}
|
||||
|
||||
/** معکوسسازی اثر پرداخت هنگام استرداد کامل / برگشت وجه. */
|
||||
private function runReversePostAction(Payment $payment): void
|
||||
{
|
||||
match ($payment->getType()) {
|
||||
Payment::TYPE_SUBSCRIPTION => $this->subscriptionService->deleteByPayment($payment),
|
||||
Payment::TYPE_SMS_WALLET => $this->reverseSmsWalletCharge($payment),
|
||||
Payment::TYPE_APPOINTMENT => $this->reverseAppointment($payment),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function reverseAppointment(Payment $payment): void
|
||||
{
|
||||
$appointment = $payment->getAppointment();
|
||||
if ($appointment === null || !$appointment->canTransitionTo(Appointment::STATUS_CANCELLED_BY_USER)) {
|
||||
return;
|
||||
}
|
||||
// لغو نوبت → اسلات بهطور خودکار آزاد میشود (availability از نوبتهای active محاسبه میشود).
|
||||
$appointment->transitionTo(Appointment::STATUS_CANCELLED_BY_USER);
|
||||
$this->em->persist($appointment);
|
||||
}
|
||||
|
||||
private function reverseSmsWalletCharge(Payment $payment): void
|
||||
{
|
||||
$meta = $payment->getMetadata() ?? [];
|
||||
$entityType = $meta['entity_type'] ?? null;
|
||||
$entityId = isset($meta['entity_id']) ? (int) $meta['entity_id'] : null;
|
||||
if ($entityType === null || $entityId === null) {
|
||||
return;
|
||||
}
|
||||
$wallet = $this->smsWalletService->getOrCreate($entityType, $entityId);
|
||||
$this->smsWalletService->deduct($wallet, $payment->getAmountRials(), 'استرداد شارژ کیف پیامک');
|
||||
}
|
||||
|
||||
private function handleAppointmentConfirmation(Payment $payment): void
|
||||
{
|
||||
$appointment = $payment->getAppointment();
|
||||
|
||||
@@ -41,7 +41,7 @@ class SecurityHeadersSubscriber implements EventSubscriberInterface
|
||||
$isPaymentPage
|
||||
? "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; "
|
||||
. "font-src https://cdn.jsdelivr.net data:; img-src data:; "
|
||||
. "form-action https://*.shaparak.ir; base-uri 'none'"
|
||||
. "form-action https://*.shaparak.ir https://sandbox.banktest.ir; base-uri 'none'"
|
||||
: "default-src 'none'"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,4 +53,15 @@ class ClinicSubscriptionRepository extends ServiceEntityRepository
|
||||
$this->getEntityManager()->persist($subscription);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function findByPayment(\App\Payment\Entity\Payment $payment): ?ClinicSubscription
|
||||
{
|
||||
return $this->findOneBy(['payment' => $payment]);
|
||||
}
|
||||
|
||||
public function remove(ClinicSubscription $subscription): void
|
||||
{
|
||||
$this->getEntityManager()->remove($subscription);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,15 @@ class SubscriptionService
|
||||
return $subscription;
|
||||
}
|
||||
|
||||
/** حذف اشتراکِ ساختهشده از یک پرداخت (هنگام استرداد/برگشت وجه). */
|
||||
public function deleteByPayment(\App\Payment\Entity\Payment $payment): void
|
||||
{
|
||||
$subscription = $this->subscriptionRepo->findByPayment($payment);
|
||||
if ($subscription !== null) {
|
||||
$this->subscriptionRepo->remove($subscription);
|
||||
}
|
||||
}
|
||||
|
||||
public function calculateExpiresAt(?int $currentExpiresAt, int $durationMonths): int
|
||||
{
|
||||
$base = max($currentExpiresAt ?? 0, time());
|
||||
|
||||
Reference in New Issue
Block a user