diff --git a/.claude/prompt/admin-payment-refund-reversal.md b/.claude/prompt/admin-payment-refund-reversal.md new file mode 100644 index 00000000..57462c4c --- /dev/null +++ b/.claude/prompt/admin-payment-refund-reversal.md @@ -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). diff --git a/.claude/prompt/mellat-sandbox-gateway.md b/.claude/prompt/mellat-sandbox-gateway.md new file mode 100644 index 00000000..0370fe09 --- /dev/null +++ b/.claude/prompt/mellat-sandbox-gateway.md @@ -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,` برمی‌گرداند (نه 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`)؛ این عمدی و برای سادگی حذف بعدی است. diff --git a/assets/admin/pages/PaymentDetailPage.tsx b/assets/admin/pages/PaymentDetailPage.tsx index 1f8febda..74f300b3 100644 --- a/assets/admin/pages/PaymentDetailPage.tsx +++ b/assets/admin/pages/PaymentDetailPage.tsx @@ -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 = { appointment: 'نوبت', @@ -35,6 +37,24 @@ export default function PaymentDetailPage() { }); const payment = data?.data; + const queryClient = useQueryClient(); + const [confirm, setConfirm] = React.useState(null); + + const action = useMutation({ + mutationFn: (kind: 'refund' | 'reverse') => + api.post>(`/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 (
@@ -72,6 +92,7 @@ export default function PaymentDetailPage() { } /> {payment.gateway}} /> {payment.ref_id} : null} /> + {payment.card_pan} : null} /> {payment.appointment_uuid && ( @@ -86,6 +107,50 @@ export default function PaymentDetailPage() { /> )}
+ + {payment.refunds && payment.refunds.length > 0 && ( +
+

استردادها

+ {payment.refunds.map((r, i) => ( + + {formatRial(r.amount)} + {r.ref && ({r.ref})} + + } + /> + ))} +
+ )} + + {canRefund && ( +
+ + +
+ )} + + confirm && action.mutate(confirm)} + onCancel={() => setConfirm(null)} + /> ) : (
diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index 84986202..c36ee67f 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -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; diff --git a/docs/api/admin.md b/docs/api/admin.md index 3255294e..712b3130 100644 --- a/docs/api/admin.md +++ b/docs/api/admin.md @@ -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 | مبلغ نامعتبر / پرداخت غیرقابل استرداد / درگاه پشتیبانی نمی‌کند | --- diff --git a/docs/api/payment.md b/docs/api/payment.md index 11f15d29..be44a7bf 100644 --- a/docs/api/payment.md +++ b/docs/api/payment.md @@ -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` diff --git a/src/Admin/Controller/AdminApiController.php b/src/Admin/Controller/AdminApiController.php index 137e87b0..65f1b61c 100644 --- a/src/Admin/Controller/AdminApiController.php +++ b/src/Admin/Controller/AdminApiController.php @@ -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( diff --git a/src/Config/Controller/SiteConfigController.php b/src/Config/Controller/SiteConfigController.php index b45c922d..6acb4efb 100644 --- a/src/Config/Controller/SiteConfigController.php +++ b/src/Config/Controller/SiteConfigController.php @@ -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', diff --git a/src/Payment/Controller/PaymentController.php b/src/Payment/Controller/PaymentController.php index 56e31dc8..0bd6370f 100644 --- a/src/Payment/Controller/PaymentController.php +++ b/src/Payment/Controller/PaymentController.php @@ -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] ?? ['خطا در پرداخت', 'خطایی در فرآیند پرداخت رخ داد.']; diff --git a/src/Payment/Entity/PaymentLog.php b/src/Payment/Entity/PaymentLog.php index da1ad0a2..1d1af686 100644 --- a/src/Payment/Entity/PaymentLog.php +++ b/src/Payment/Entity/PaymentLog.php @@ -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] diff --git a/src/Payment/Gateway/GatewayFactory.php b/src/Payment/Gateway/GatewayFactory.php index f87a7120..acb14360 100644 --- a/src/Payment/Gateway/GatewayFactory.php +++ b/src/Payment/Gateway/GatewayFactory.php @@ -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' => 'بانک ملت (آزمایشی)']]; } diff --git a/src/Payment/Gateway/MellatGateway.php b/src/Payment/Gateway/MellatGateway.php index b9d3b042..18ec11c7 100644 --- a/src/Payment/Gateway/MellatGateway.php +++ b/src/Payment/Gateway/MellatGateway.php @@ -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 << + + + {$p['terminalId']} + {$p['userName']} + {$p['userPassword']} + {$p['orderId']} + {$p['saleOrderId']} + {$p['saleReferenceId']} + {$refundAmountRials} + + + +XML; + } + + private function buildReversalPayload(string $saleOrderId, string $saleReferenceId): string + { + $p = $this->reversalPayload($saleOrderId, $saleReferenceId); + return << + + + {$p['terminalId']} + {$p['userName']} + {$p['userPassword']} + {$p['orderId']} + {$p['saleOrderId']} + {$p['saleReferenceId']} + + + +XML; + } + private function parseResCode(string $xml): string { preg_match('/(.*?)<\/return>/', $xml, $m); diff --git a/src/Payment/Gateway/MockGateway.php b/src/Payment/Gateway/MockGateway.php index 6649de9c..79f4a2af 100644 --- a/src/Payment/Gateway/MockGateway.php +++ b/src/Payment/Gateway/MockGateway.php @@ -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); + } } diff --git a/src/Payment/Gateway/PaymentGatewayInterface.php b/src/Payment/Gateway/PaymentGatewayInterface.php index da2fefee..7df195ee 100644 --- a/src/Payment/Gateway/PaymentGatewayInterface.php +++ b/src/Payment/Gateway/PaymentGatewayInterface.php @@ -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; } diff --git a/src/Payment/Gateway/PaymentRefundResult.php b/src/Payment/Gateway/PaymentRefundResult.php new file mode 100644 index 00000000..8cdd458e --- /dev/null +++ b/src/Payment/Gateway/PaymentRefundResult.php @@ -0,0 +1,12 @@ +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: 'برگشت وجه برای این درگاه پشتیبانی نمی‌شود'); + } } diff --git a/src/Payment/Service/PaymentManager.php b/src/Payment/Service/PaymentManager.php index 4d8878d9..84c6495c 100644 --- a/src/Payment/Service/PaymentManager.php +++ b/src/Payment/Service/PaymentManager.php @@ -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(); diff --git a/src/Shared/EventSubscriber/SecurityHeadersSubscriber.php b/src/Shared/EventSubscriber/SecurityHeadersSubscriber.php index 13d31159..0782a43e 100644 --- a/src/Shared/EventSubscriber/SecurityHeadersSubscriber.php +++ b/src/Shared/EventSubscriber/SecurityHeadersSubscriber.php @@ -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'" ); } diff --git a/src/Subscription/Repository/ClinicSubscriptionRepository.php b/src/Subscription/Repository/ClinicSubscriptionRepository.php index 5dda885e..e248219c 100644 --- a/src/Subscription/Repository/ClinicSubscriptionRepository.php +++ b/src/Subscription/Repository/ClinicSubscriptionRepository.php @@ -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(); + } } diff --git a/src/Subscription/Service/SubscriptionService.php b/src/Subscription/Service/SubscriptionService.php index 792d98c3..ed67eee5 100644 --- a/src/Subscription/Service/SubscriptionService.php +++ b/src/Subscription/Service/SubscriptionService.php @@ -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());