diff --git a/.claude/prompt/mellat-production-soap.md b/.claude/prompt/mellat-production-soap.md new file mode 100644 index 00000000..93434d34 --- /dev/null +++ b/.claude/prompt/mellat-production-soap.md @@ -0,0 +1,228 @@ +# بردن درگاه ملت روی محیط واقعی (production) با SOAP native + +## پروژه + +`clinicpro` (backend / Payment) + +## زمینه + +درگاه ملت الان دو مسیر دارد: +- **sandbox** (`mellat_sandbox=1`): REST روی `banktest.ir` (کار می‌کند، برای تست). +- **prod** (پیش‌فرض): SOAP روی `bpm.shaparak.ir` که فعلاً با **POST خام XML** توسط `Symfony HttpClient` به `pgwchannel/services/pgw` زده می‌شود. + +طبق آموزش رسمی بانک (`mellat-payment-gateway-symfony.md`, Mellat PGW Tech Doc v1.38)، روش توصیه‌شده برای prod استفاده از **`SoapClient` بومی PHP روی WSDL** است (نه POST خام). اکستنشن `soap` روی سرور فعال است (تأیید شد). مسیر prod باید به `SoapClient` تبدیل شود تا با درگاه واقعی پایدار کار کند؛ مسیر sandbox (REST) دست‌نخورده می‌ماند. + +**هدف:** مسیر prod درگاه ملت با `SoapClient` واقعی کار کند و آمادهٔ رفتن روی دامنهٔ عملیاتی باشد. sandbox برای تست باقی بماند. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `clinicpro/src/Payment/Gateway/MellatGateway.php` | جایگزینی مسیر prod (POST خام XML) با `SoapClient`؛ حفظ مسیر sandbox REST | +| `clinicpro/config/services.yaml` | (در صورت نیاز) binding `mellat_wsdl_url` | +| `clinicpro/.env` | مقادیر prod (WSDL/redirect) + credentials واقعی | +| `clinicpro/docs/api/payment.md` | مستندسازی مسیر prod SOAP | + +## وضعیت فعلی + +### `MellatGateway.php` — ثابت‌ها و مسیر prod فعلی (POST خام) + +```php +private const PAYMENT_URL = 'https://bpm.shaparak.ir/pgwchannel/startpay.mellat'; +private const SERVICE_URL = 'https://bpm.shaparak.ir/pgwchannel/services/pgw'; // prod SOAP (POST خام) + +// sandbox (REST) — بدون تغییر باقی می‌ماند +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'; +// ... + +public function initiate(...) { + if ($this->sandbox()) { /* REST bpPayRequest */ } + else { + $response = $this->httpClient->request('POST', self::SERVICE_URL, [ + 'body' => $this->buildRequestPayload(...), // ← XML خام + 'headers' => ['Content-Type' => 'text/xml; charset=utf-8', 'SOAPAction' => '""'], + ]); + $resCode = $this->parseResCode($response->getContent()); // ← regex روی XML + $refId = $this->parseRefId($response->getContent()); + } + // ... +} + +public function verify(array $callbackData) { + if ($this->sandbox()) { /* REST verify + settle جدا */ } + else { + $response = $this->httpClient->request('POST', self::SERVICE_URL, [ + 'body' => $this->buildVerifySettlePayload($saleOrderId, $saleReferenceId), // ← bpVerifySettleRequest خام + // ... + ]); + $verifyCode = $this->parseResCode($response->getContent()); + } +} + +// refund()/reverse() prod هم همین الگوی POST خام را دارند (buildRefundPayload/buildReversalPayload) +// helperهای فقط-prod: buildRequestPayload, buildVerifySettlePayload, buildRefundPayload, +// buildReversalPayload, parseResCode, parseRefId +``` + +اعتبارنامه از `cfg()` خوانده می‌شود: +```php +private function cfg(string $key, ?string $envFallback): string +{ + if ($this->sandbox()) { /* const های sandbox */ } + return (string) ($this->configRepo->get($key) ?: $envFallback ?? ''); +} +// prod: mellat_terminal_id / mellat_username / mellat_password از site_config یا env (MELLAT_*) +``` + +## وظایف + +### ۱. افزودن WSDL prod + سازندهٔ تنبل SoapClient + +یک ثابت WSDL و یک متد که `SoapClient` را lazy می‌سازد (فقط prod، فقط وقتی لازم شد؛ تا sandbox/mock را نشکند و هزینهٔ ساخت WSDL بی‌مورد نپردازد). + +```php +private const PROD_WSDL = 'https://bpm.shaparak.ir/pgwchannel/services/pgw?wsdl'; + +private ?\SoapClient $soap = null; + +/** WSDL قابل override با کلید تنظیم mellat_wsdl_url (برای WSDL تستِ pgw.dev اگر لازم شد). */ +private function wsdlUrl(): string +{ + return (string) ($this->configRepo->get('mellat_wsdl_url') ?: self::PROD_WSDL); +} + +private function soap(): \SoapClient +{ + if ($this->soap === null) { + $this->soap = new \SoapClient($this->wsdlUrl(), [ + 'trace' => true, + 'exceptions' => true, + 'encoding' => 'UTF-8', + 'connection_timeout' => 10, + ]); + } + return $this->soap; +} + +/** پارامترهای مشترک احراز هویت prod. */ +private function soapAuth(): 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), + ]; +} +``` + +### ۲. `initiate()` — مسیر prod با SoapClient + +مسیر sandbox بدون تغییر. مسیر else را جایگزین کن: + +```php +} else { + $r = $this->soap()->bpPayRequest($this->soapAuth() + [ + 'orderId' => (int) $orderId, + 'amount' => $amountRials, + 'localDate' => $this->date(), + 'localTime' => $this->time(), + 'additionalData' => '', + 'callBackUrl' => $callbackUrl, + 'payerId' => 0, + ]); + // پاسخ "resCode,refId" + $parts = array_map('trim', explode(',', (string) ($r->return ?? ''), 2)); + $resCode = $parts[0] ?? '-1'; + $refId = $parts[1] ?? ''; +} +``` + +`SoapFault` باید مثل بقیه در `catch (\Throwable $e)` موجود گرفته شود (هست). + +### ۳. `verify()` — prod با verify + settle جدا (طبق آموزش) + +طبق آموزش، prod هم مثل sandbox **verify سپس settle جدا** انجام شود (به‌جای `bpVerifySettleRequest` ترکیبی). کدها: verify `0`/`43` موفق، settle `0`/`45` موفق. + +```php +} else { + $auth = $this->soapAuth() + [ + 'orderId' => (int) $saleOrderId, + 'saleOrderId' => (int) $saleOrderId, + 'saleReferenceId' => (int) $saleReferenceId, + ]; + $vc = (string) ($this->soap()->bpVerifyRequest($auth)->return ?? '-1'); + if (!in_array($vc, ['0', '43'], true)) { + return new PaymentVerifyResult(false, errorMessage: $this->mellatMessage($vc)); + } + $sc = (string) ($this->soap()->bpSettleRequest($auth)->return ?? '-1'); + if (!in_array($sc, ['0', '45'], true)) { + return new PaymentVerifyResult(false, errorMessage: $this->mellatMessage($sc)); + } + return new PaymentVerifyResult(true, referenceId: $saleReferenceId); +} +``` + +> نکته: بعد از این تغییر، پیام خطای prod هم از `mellatMessage()` استفاده کند (نه رشتهٔ خام «Verify failed»). چک `ResCode === '17'` (انصراف) و ناقص‌بودن `saleOrderId/saleReferenceId` که قبل از try هست، حفظ شود. + +### ۴. `refund()` / `reverse()` — prod با SoapClient + +```php +// refund prod: +$r = $this->soap()->bpRefundRequest($this->soapAuth() + [ + 'orderId' => $this->uniqueOrderId(), + 'saleOrderId' => (int) $saleOrderId, + 'saleReferenceId' => (int) $saleReferenceId, + 'refundAmount' => $refundAmountRials, +]); +$parts = array_map('trim', explode(',', (string) ($r->return ?? ''), 2)); +$code = $parts[0] ?? '-1'; +// code !== '0' → PaymentRefundResult(false, mellatMessage($code)); else refundRefId = $parts[1] + +// reverse prod: +$code = (string) ($this->soap()->bpReversalRequest($this->soapAuth() + [ + 'orderId' => $this->uniqueOrderId(), + 'saleOrderId' => (int) $saleOrderId, + 'saleReferenceId' => (int) $saleReferenceId, +])->return ?? '-1'); +// in_array($code, ['0','48']) → success +``` + +### ۵. حذف helperهای مردهٔ prod + +بعد از سوییچ به SoapClient، این‌ها دیگر استفاده نمی‌شوند و باید حذف شوند (اگر جای دیگری استفاده نشده‌اند — بررسی کن): +`buildRequestPayload`, `buildVerifySettlePayload`, `buildRefundPayload`, `buildReversalPayload`, `parseResCode`, `parseRefId`, و ثابت `SERVICE_URL` (POST خام). `restParts`/`restCall`/`restHeaders` و ثابت‌های sandbox باقی می‌مانند (sandbox REST همچنان از آن‌ها استفاده می‌کند). + +### ۶. تنظیمات محیط prod + +- `.env` (سرور عملیاتی): + ```dotenv + APP_BASE_URL=https://<دامنهٔ-ثبت‌شده-نزد-ملت> # مثلا https://clinic-pro.ir + # اعتبارنامهٔ واقعی (یا از /admin/settings ست شود): + MELLAT_TERMINAL_ID= + MELLAT_USERNAME= + MELLAT_PASSWORD= + # WSDL prod پیش‌فرض است؛ فقط اگر WSDL تستِ pgw.dev خواستی: + # کلید site_config: mellat_wsdl_url = https://pgw.dev.bpmellat.ir/pgwchannel/services/pgw?wsdl + ``` +- در `/admin/settings`: `mellat_sandbox=0` (خاموش) و `payment_test_mode=0`؛ `mellat_enabled=1` و در صورت نبود env، `mellat_terminal_id/username/password` را همان‌جا ست کن. + +## نکات مهم (چک‌لیست عملیاتی prod) + +- **IP سرور** باید توسط «شرکت به‌پرداخت ملت» whitelist شود، وگرنه کد `421` (IP نامعتبر). +- **دامنهٔ callBackUrl** = `APP_BASE_URL` باید **دقیقاً دامنهٔ ثبت‌شده نزد ملت** باشد (نه IP)، وگرنه کد `62`. `callbackUrl()` در `PaymentManager` از `APP_BASE_URL` می‌سازد. +- **ext-soap** روی سرور prod فعال باشد (`php -m | grep soap`) — روی ddev هست. +- پورت‌های `443`/`80` خروجی سرور به شبکهٔ شاپرک باز باشد. +- `orderId` مرحلهٔ Pay = `payment.id` عددی (هست)؛ refund/reverse از `uniqueOrderId()` یکتا استفاده می‌کنند (هست). +- چک ضد-دستکاری callback (`RefId==gateway_token`, `SaleOrderId==payment.id`) در `PaymentManager::processCallback` برای prod هم فعال است — دست نخورد. +- **Auto-reversal**: اگر verify ظرف ۲۰ دقیقه بعد از پرداخت موفق ارسال نشود، بانک خودکار برگشت می‌زند؛ چون verify در callback بلافاصله انجام می‌شود مشکلی نیست. +- SoapClient روی خطای شبکه/WSDL `SoapFault` می‌اندازد که در `catch (\Throwable)` موجود گرفته و به پیام کاربر تبدیل می‌شود. +- بعد از تغییر: `ddev exec php -l`، `cache:clear`، و یک تراکنش واقعی با **مبلغ کم** تست شود (prod را نمی‌توان با mock تست کرد؛ فقط syntax + بارگذاری WSDL قابل بررسی خودکار است). +- `docs/api/payment.md` را به‌روزرسانی کن (prod = SoapClient؛ verify+settle جدا). + +## آنچه نباید تغییر کند + +- مسیر **sandbox REST** (`initiate`/`verify`/`refund`/`reverse` وقتی `sandbox()` true است). +- منطق `PaymentManager` (transaction/lock/tamper/reverse-post-action). +- `SepGateway`/`MockGateway`. diff --git a/assets/admin/pages/SettingsPage.tsx b/assets/admin/pages/SettingsPage.tsx index 38c3f04d..50687c72 100644 --- a/assets/admin/pages/SettingsPage.tsx +++ b/assets/admin/pages/SettingsPage.tsx @@ -37,6 +37,8 @@ const schema = z.object({ // payment gateways payment_test_mode: z.string(), mellat_enabled: z.string(), + mellat_sandbox: z.string(), + mellat_wsdl_url: z.string(), mellat_terminal_id: z.string(), mellat_username: z.string(), mellat_password: z.string(), @@ -65,6 +67,8 @@ const toForm = (s: Partial): FormValues => ({ appointment_fee_rials: s.appointment_fee_rials ?? '150000', payment_test_mode: s.payment_test_mode ?? '0', mellat_enabled: s.mellat_enabled ?? '1', + mellat_sandbox: s.mellat_sandbox ?? '0', + mellat_wsdl_url: s.mellat_wsdl_url ?? '', mellat_terminal_id: s.mellat_terminal_id ?? '', mellat_username: s.mellat_username ?? '', mellat_password: s.mellat_password ?? '', @@ -202,6 +206,7 @@ export default function SettingsPage() { const paymentTestMode = watch('payment_test_mode') === '1'; const mellatEnabled = watch('mellat_enabled') === '1'; + const mellatSandbox = watch('mellat_sandbox') === '1'; const sepEnabled = watch('sep_enabled') === '1'; const apptCommissionEnabled = watch('appointment_commission_enabled') === '1'; const upgradeCommissionEnabled = watch('upgrade_commission_enabled') === '1'; @@ -423,6 +428,15 @@ export default function SettingsPage() { +
+ + + +
+ {mellatSandbox ? 'Sandbox (آزمایشگاه)' : 'واقعی'} + toggle('mellat_sandbox', mellatSandbox)} label="حالت Sandbox ملت" /> +
+
{/* سپ */} diff --git a/docs/api/payment.md b/docs/api/payment.md index be44a7bf..02b6e5ee 100644 --- a/docs/api/payment.md +++ b/docs/api/payment.md @@ -57,6 +57,9 @@ **افزودن درگاه جدید (Open/Closed):** یک کلاس جدید implements `PaymentGatewayInterface` بساز، در `GatewayFactory::$gateways` + `LABELS` ثبت کن. `PaymentController`/`PaymentManager` تغییر نمی‌کنند. **درگاه ملت (BPM) — نکات پیاده‌سازی طبق مستند رسمی ۱.۳۸:** +- **prod = SOAP با `SoapClient` بومی** روی WSDL عملیاتی `https://bpm.shaparak.ir/pgwchannel/services/pgw?wsdl` (متدها: `bpPayRequest`، `bpVerifyRequest`+`bpSettleRequest` جدا، `bpRefundRequest`، `bpReversalRequest`). WSDL با کلید `mellat_wsdl_url` قابل override است (مثلاً WSDL تستِ pgw.dev). نیازمند اکستنشن `soap` روی سرور. **sandbox = REST** (banktest.ir) بدون تغییر. +- **چک‌لیست عملیاتی prod:** (۱) IP سرور نزد «به‌پرداخت ملت» whitelist شود (وگرنه کد `421`). (۲) `APP_BASE_URL` = دامنهٔ ثبت‌شدهٔ پذیرنده (نه IP؛ وگرنه کد `62`). (۳) اعتبارنامهٔ واقعی در env (`MELLAT_*`) یا `/admin/settings`. (۴) `mellat_sandbox=0` و `payment_test_mode=0`. (۵) پورت‌های ۴۴۳/۸۰ خروجی باز. +- کدهای پاسخ با `mellatMessage()` به پیام فارسی نگاشت می‌شوند. - `orderId` ملت از نوع **long (عددی)** است؛ به همین دلیل `PaymentManager` هنگام init، **`payment.id` عددی** را به‌عنوان orderId درگاه می‌فرستد (نه رشتهٔ `ORD-…`). جستجوی پرداخت در callback از طریق query `order_id` (رشتهٔ `ORD-…`) انجام می‌شود. - **verify+settle** با یک فراخوانی `bpVerifySettleRequest` انجام می‌شود و به `saleOrderId` (= همان `payment.id` مرحلهٔ Pay) و `saleReferenceId` (که بانک در callback POST می‌فرستد) نیاز دارد — **نه** `RefId`. کدهای `0`/`43`/`45` (موفق/قبلاً verify/قبلاً settle) موفق تلقی می‌شوند. `reference_id` ذخیره‌شده = `SaleReferenceId`. - **چک ضد-دستکاری (اجباری مستند):** در callback، `RefId` بازگشتی باید با `gateway_token` ذخیره‌شده و `SaleOrderId` با `payment.id` برابر باشد؛ در غیر این‌صورت تراکنش `failed` می‌شود (این چک برای درگاه‌هایی که این فیلدها را برنمی‌گردانند، مثل سپ، رد می‌شود). diff --git a/src/Config/Controller/SiteConfigController.php b/src/Config/Controller/SiteConfigController.php index 6acb4efb..17f82c9c 100644 --- a/src/Config/Controller/SiteConfigController.php +++ b/src/Config/Controller/SiteConfigController.php @@ -35,6 +35,7 @@ class SiteConfigController extends BaseController 'payment_allowed_frontend_hosts', 'mellat_enabled', 'mellat_sandbox', + 'mellat_wsdl_url', 'mellat_terminal_id', 'mellat_username', 'mellat_password', diff --git a/src/Payment/Gateway/MellatGateway.php b/src/Payment/Gateway/MellatGateway.php index 18ec11c7..98f8e6d1 100644 --- a/src/Payment/Gateway/MellatGateway.php +++ b/src/Payment/Gateway/MellatGateway.php @@ -9,8 +9,8 @@ use Symfony\Contracts\HttpClient\HttpClientInterface; class MellatGateway implements PaymentGatewayInterface { private const PAYMENT_URL = 'https://bpm.shaparak.ir/pgwchannel/startpay.mellat'; - // endpoint سرویس SOAP (بدون ?wsdl؛ ?wsdl فقط توصیفِ سرویس است و POST به آن 500 می‌دهد). - private const SERVICE_URL = 'https://bpm.shaparak.ir/pgwchannel/services/pgw'; + // WSDL عملیاتی؛ prod با SoapClient بومی روی این WSDL کار می‌کند (طبق آموزش رسمی v1.38). + private const PROD_WSDL = 'https://bpm.shaparak.ir/pgwchannel/services/pgw?wsdl'; // sandbox banktest.ir (موقت). پشت فلگ mellat_sandbox. // نکته: SOAP sandbox (pgwchannel) روی banktest 502 می‌دهد؛ فقط REST (ipg2) سالم است، @@ -22,6 +22,8 @@ class MellatGateway implements PaymentGatewayInterface private const SANDBOX_USERNAME = 'user134759344'; private const SANDBOX_PASSWORD = '17384843'; + private ?\SoapClient $soap = null; + public function __construct( private readonly HttpClientInterface $httpClient, private readonly SiteConfigRepository $configRepo, @@ -53,6 +55,40 @@ class MellatGateway implements PaymentGatewayInterface return $this->sandbox() ? self::SANDBOX_PAYMENT_URL : self::PAYMENT_URL; } + // ── prod SOAP ─────────────────────────────────────────────────────────────── + + /** WSDL قابل override با کلید تنظیم mellat_wsdl_url (برای WSDL تستِ pgw.dev اگر لازم شد). */ + private function wsdlUrl(): string + { + return (string) ($this->configRepo->get('mellat_wsdl_url') ?: self::PROD_WSDL); + } + + /** SoapClient تنبل — فقط prod و فقط وقتی لازم شد ساخته می‌شود. */ + private function soap(): \SoapClient + { + if ($this->soap === null) { + $this->soap = new \SoapClient($this->wsdlUrl(), [ + 'trace' => true, + 'exceptions' => true, + 'encoding' => 'UTF-8', + 'connection_timeout' => 10, + ]); + } + return $this->soap; + } + + /** پارامترهای مشترک احراز هویت prod. */ + private function soapAuth(): 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), + ]; + } + + // ── sandbox REST ───────────────────────────────────────────────────────────── + /** هدر Basic Auth برای REST sandbox (base64 از userName:userPassword). */ private function restHeaders(): array { @@ -116,21 +152,23 @@ class MellatGateway implements PaymentGatewayInterface ])->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()); + $r = $this->soap()->bpPayRequest($this->soapAuth() + [ + 'orderId' => (int) $orderId, + 'amount' => $amountRials, + 'localDate' => $this->date(), + 'localTime' => $this->time(), + 'additionalData' => '', + 'callBackUrl' => $callbackUrl, + 'payerId' => 0, + ]); + // پاسخ "resCode,refId" + $parts = array_map('trim', explode(',', (string) ($r->return ?? ''), 2)); + $resCode = $parts[0] ?? '-1'; + $refId = $parts[1] ?? ''; } if ($resCode !== '0') { - return new PaymentInitResult(false, errorMessage: "Mellat error: $resCode"); + return new PaymentInitResult(false, errorMessage: $this->mellatMessage($resCode)); } $redirectUrl = $this->paymentUrl() . '?RefId=' . $refId; @@ -161,7 +199,7 @@ class MellatGateway implements PaymentGatewayInterface } if ($resCode !== '0') { - return new PaymentVerifyResult(false, errorMessage: "Payment failed: $resCode"); + return new PaymentVerifyResult(false, errorMessage: $this->mellatMessage($resCode)); } // برای تأیید و واریز، ملت به saleOrderId (همان orderId مرحلهٔ Pay) و @@ -171,9 +209,8 @@ class MellatGateway implements PaymentGatewayInterface } try { + // هم sandbox و هم prod: verify سپس settle جدا. 0=موفق، 43=قبلاً verify، 45=قبلاً settle. 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, @@ -184,30 +221,28 @@ class MellatGateway implements PaymentGatewayInterface ]; $vc = $this->restCall('/bpVerifyRequest', $payload); if (!in_array($vc, ['0', '43'], true)) { - return new PaymentVerifyResult(false, errorMessage: "Verify failed: $vc"); + return new PaymentVerifyResult(false, errorMessage: $this->mellatMessage($vc)); } $sc = $this->restCall('/bpSettleRequest', $payload); if (!in_array($sc, ['0', '45'], true)) { - return new PaymentVerifyResult(false, errorMessage: "Settle failed: $sc"); + return new PaymentVerifyResult(false, errorMessage: $this->mellatMessage($sc)); } return new PaymentVerifyResult(true, referenceId: $saleReferenceId); } - $response = $this->httpClient->request( - 'POST', - self::SERVICE_URL, - [ - 'body' => $this->buildVerifySettlePayload($saleOrderId, $saleReferenceId), - 'headers' => ['Content-Type' => 'text/xml; charset=utf-8', 'SOAPAction' => '""'], - 'timeout' => 10, - ] - ); - $verifyCode = $this->parseResCode($response->getContent()); - // 0 = موفق، 43 = پیشتر verify شده، 45 = پیشتر settle شده (هر دو idempotent = موفق). - if (!in_array($verifyCode, ['0', '43', '45'], true)) { - return new PaymentVerifyResult(false, errorMessage: "Verify failed: $verifyCode"); + $auth = $this->soapAuth() + [ + 'orderId' => (int) $saleOrderId, + 'saleOrderId' => (int) $saleOrderId, + 'saleReferenceId' => (int) $saleReferenceId, + ]; + $vc = (string) ($this->soap()->bpVerifyRequest($auth)->return ?? '-1'); + if (!in_array($vc, ['0', '43'], true)) { + return new PaymentVerifyResult(false, errorMessage: $this->mellatMessage($vc)); + } + $sc = (string) ($this->soap()->bpSettleRequest($auth)->return ?? '-1'); + if (!in_array($sc, ['0', '45'], true)) { + return new PaymentVerifyResult(false, errorMessage: $this->mellatMessage($sc)); } - return new PaymentVerifyResult(true, referenceId: $saleReferenceId); } catch (\Throwable $e) { $this->logger->error(sprintf('Payment verify failed (mellat): %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'saleReferenceId' => $saleReferenceId]); @@ -215,55 +250,6 @@ class MellatGateway implements PaymentGatewayInterface } } - private function buildRequestPayload(int $amount, string $orderId, string $callbackUrl): string - { - $terminalId = $this->cfg('mellat_terminal_id', $this->terminalId); - $username = $this->cfg('mellat_username', $this->username); - $password = $this->cfg('mellat_password', $this->password); - - return << - - - {$terminalId} - {$username} - {$password} - {$orderId} - {$amount} - {$this->date()} - {$this->time()} - - {$callbackUrl} - 0 - - - -XML; - } - - /** تأیید و واریز یکجا (bpVerifySettleRequest). orderId می‌تواند برابر saleOrderId باشد. */ - private function buildVerifySettlePayload(string $saleOrderId, string $saleReferenceId): string - { - $terminalId = $this->cfg('mellat_terminal_id', $this->terminalId); - $username = $this->cfg('mellat_username', $this->username); - $password = $this->cfg('mellat_password', $this->password); - - return << - - - {$terminalId} - {$username} - {$password} - {$saleOrderId} - {$saleOrderId} - {$saleReferenceId} - - - -XML; - } - public function refund(string $saleOrderId, string $saleReferenceId, int $refundAmountRials): PaymentRefundResult { try { @@ -276,12 +262,13 @@ XML; ])->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)]; + $r = $this->soap()->bpRefundRequest($this->soapAuth() + [ + 'orderId' => $this->uniqueOrderId(), + 'saleOrderId' => (int) $saleOrderId, + 'saleReferenceId' => (int) $saleReferenceId, + 'refundAmount' => $refundAmountRials, + ]); + $parts = array_map('trim', explode(',', (string) ($r->return ?? ''), 2)); } $code = $parts[0] ?? '-1'; if ($code !== '0') { @@ -300,12 +287,11 @@ XML; 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); + $code = (string) ($this->soap()->bpReversalRequest($this->soapAuth() + [ + 'orderId' => $this->uniqueOrderId(), + 'saleOrderId' => (int) $saleOrderId, + 'saleReferenceId' => (int) $saleReferenceId, + ])->return ?? '-1'); } // 0 = موفق، 48 = پیشتر reverse شده (idempotent = موفق). if (!in_array($code, ['0', '48'], true)) { @@ -330,6 +316,7 @@ XML; '24' => 'اطلاعات کاربری پذیرنده نامعتبر است', '25' => 'مبلغ نامعتبر است', '34' => 'خطای سیستمی درگاه (در محیط تست، استرداد پشتیبانی نمی‌شود)', + '41' => 'شماره درخواست تکراری است', '42' => 'تراکنش خرید (Sale) یافت نشد', '43' => 'این تراکنش پیشتر تأیید شده است', '44' => 'درخواست تأیید یافت نشد', @@ -340,6 +327,7 @@ XML; '51' => 'تراکنش تکراری است', '61' => 'خطا در واریز', '62' => 'مسیر بازگشت در دامنهٔ ثبت‌شدهٔ پذیرنده نیست', + '421' => 'IP نامعتبر است (به بانک اعلام نشده)', ]; return ($map[$code] ?? 'خطای درگاه') . " (کد $code)"; } @@ -350,13 +338,13 @@ XML; return (int) substr((string) (int) (microtime(true) * 1000), -12); } - /** بدنهٔ JSON مشترک refund (REST). */ + /** بدنهٔ JSON مشترک refund (REST sandbox). */ private function refundPayload(string $saleOrderId, string $saleReferenceId, int $refundAmountRials): array { return $this->reversalPayload($saleOrderId, $saleReferenceId) + ['refundAmount' => $refundAmountRials]; } - /** بدنهٔ JSON مشترک reverse (REST). */ + /** بدنهٔ JSON مشترک reverse (REST sandbox). */ private function reversalPayload(string $saleOrderId, string $saleReferenceId): array { return [ @@ -369,59 +357,6 @@ XML; ]; } - 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); - $parts = explode(',', $m[1] ?? ''); - return trim($parts[0] ?? '-1'); - } - - private function parseRefId(string $xml): string - { - preg_match('/(.*?)<\/return>/', $xml, $m); - $parts = explode(',', $m[1] ?? ''); - return trim($parts[1] ?? ''); - } - private function date(): string { return date('Ymd');