isConfigured(); } public function isConfigured(): bool { return $this->baseUrl() !== '' && $this->clientId() !== '' && $this->clientSecret() !== '' && $this->gatewayName() !== ''; } public function returnUrl(): string { $fromSettings = $this->config->getString('external_payment_return_url', ''); if ($fromSettings !== '') { return $fromSettings; } // پیش‌فرض: صفحه فرانت برای نمایش نتیجه + verify سمت سرور $site = $this->config->resolvePublicSiteUrl(request()); if ($site === '') { $appUrl = rtrim((string) config('app.url'), '/'); // اگر APP_URL با /server تمام شود، دامنه فرانت همان host بدون /server است $site = preg_replace('#/server$#', '', $appUrl) ?: $appUrl; } return rtrim($site, '/') . '/wallet/payment-result'; } public function webhookUrl(): string { $fromSettings = $this->config->getString('external_payment_webhook_url', ''); if ($fromSettings !== '') { return $fromSettings; } return url('/customer/wallet/payment/webhook/' . $this->key()); } public function initiate(array $payload): array { if (!$this->isConfigured()) { throw new RuntimeException('تنظیمات درگاه Montify ناقص است'); } $path = '/external-payment/initiate'; $body = [ 'amount' => (int) $payload['amount'], 'reference_id' => (string) $payload['reference_id'], 'gateway' => (string) ($payload['gateway'] ?? $this->gatewayName()), 'return_url' => (string) ($payload['return_url'] ?? $this->returnUrl()), 'webhook_url' => (string) ($payload['webhook_url'] ?? $this->webhookUrl()), ]; if (!empty($payload['description'])) { $body['description'] = (string) $payload['description']; } if (!empty($payload['metadata']) && is_array($payload['metadata'])) { $body['metadata'] = $payload['metadata']; } $signer = $this->signer(); $rawBody = $signer->stableStringify($body); $headers = $signer->signRequest('POST', $path, $body); try { // مهم: post($url, []) بدنهٔ withBody را خالی می‌کند — حتماً send بدون data $response = Http::withHeaders($headers) ->withBody($rawBody, 'application/json') ->acceptJson() ->timeout(30) ->send('POST', $this->baseUrl() . $path); } catch (\Throwable $e) { report($e); throw new RuntimeException('اتصال به سرور درگاه برقرار نشد'); } $data = $response->json(); if (!$response->successful() || !is_array($data) || empty($data['payment_url'])) { $errorCode = is_array($data) ? (string) ($data['error'] ?? '') : ''; $remoteMessage = is_array($data) ? (string) ($data['message'] ?? $data['error'] ?? 'initiate failed') : 'initiate failed'; \Log::warning('montify.initiate_failed', [ 'status' => $response->status(), 'error' => $errorCode, 'message' => $remoteMessage, 'return_url' => $body['return_url'] ?? null, 'webhook_url' => $body['webhook_url'] ?? null, 'gateway' => $body['gateway'] ?? null, 'reference_id' => $body['reference_id'] ?? null, 'amount' => $body['amount'] ?? null, 'body' => is_array($data) ? $data : $response->body(), ]); throw new RuntimeException($this->mapInitiateError($errorCode, $remoteMessage)); } return [ 'payment_url' => (string) $data['payment_url'], 'session_id' => $data['session_id'] ?? null, 'reference_id' => $data['reference_id'] ?? $payload['reference_id'], 'expires_at' => $data['expires_at'] ?? null, ]; } private function mapInitiateError(string $errorCode, string $fallback): string { return match ($errorCode) { 'invalid_signature' => 'امضای درخواست درگاه نامعتبر است (secret یا ساعت سرور را بررسی کنید)', 'expired_timestamp' => 'ساعت سرور با درگاه همخوان نیست', 'replay_detected' => 'درخواست تکراری شناسایی شد؛ دوباره تلاش کنید', 'invalid_return_url' => 'آدرس برگشت (return_url) در whitelist پارتنر Montify ثبت نشده است', 'invalid_gateway' => 'نام درگاه داخل Montify نامعتبر است', 'amount_too_low' => 'مبلغ کمتر از حد مجاز درگاه است', 'duplicate_reference' => 'شناسه پرداخت تکراری است؛ دوباره تلاش کنید', 'gateway_not_configured' => 'درگاه انتخاب‌شده در Montify پیکربندی نشده است', 'missing_auth_headers' => 'هدرهای احراز هویت به درگاه ارسال نشد', default => $fallback !== '' && $fallback !== 'initiate failed' ? $fallback : 'مشکل در ارتباط با درگاه', }; } public function handleReturn(Request $request): array { return $this->handleReturnPayload($this->extractQueryArray($request)); } /** * @param array $rawQuery * @return array{ok: bool, withdraw: ?\App\Models\Withdraw, http_status?: int, message?: string} */ public function handleReturnPayload(array $rawQuery): array { $query = $this->normalizeReturnQuery($rawQuery); $signer = $this->signer(); if ($signer->matchReturnSignatureVariant($query) === null) { \Log::warning('montify.return_invalid_signature', [ 'keys' => array_keys($query), 'reference_id' => $query['reference_id'] ?? null, 'status' => $query['status'] ?? null, 'amount' => $query['amount'] ?? null, 'timestamp' => $query['timestamp'] ?? null, 'has_track_number' => array_key_exists('track_number', $query), ]); return ['ok' => false, 'withdraw' => null, 'http_status' => 401, 'message' => 'امضای نامعتبر']; } if (!$signer->isTimestampFresh($query['timestamp'] ?? null)) { return ['ok' => false, 'withdraw' => null, 'http_status' => 401, 'message' => 'زمان درخواست منقضی شده']; } $referenceId = (string) ($query['reference_id'] ?? ''); $status = (string) ($query['status'] ?? ''); $amount = (int) ($query['amount'] ?? 0); $trackNumber = (string) ($query['track_number'] ?? ''); $sessionId = (string) ($query['session_id'] ?? ''); $withdraw = $this->creditor->findByReference($referenceId); if (!$withdraw) { return ['ok' => false, 'withdraw' => null, 'http_status' => 404, 'message' => 'سفارش یافت نشد']; } if ($amount > 0 && (int) $withdraw->cash !== $amount) { return ['ok' => false, 'withdraw' => $withdraw, 'http_status' => 422, 'message' => 'مبلغ پرداختی دستکاری شده !']; } if ($status === 'success') { $this->creditor->markPaidAndCredit( $withdraw, $trackNumber !== '' ? $trackNumber : $sessionId, 'واریز درگاه Montify' ); } elseif ((int) $withdraw->sts === 1) { $withdraw->sts = 3; $withdraw->save(); } return [ 'ok' => true, 'withdraw' => $withdraw->fresh(['customer']), ]; } /** * استخراج امن پارامترهای query — بدون فرض ParameterBag یا array. * * @return array */ private function extractQueryArray(Request $request): array { // دسترسی مستقیم به bag همیشه ParameterBag است if (isset($request->query) && is_object($request->query) && method_exists($request->query, 'all')) { return $request->query->all(); } $raw = $request->query(); if (is_array($raw)) { return $raw; } if (is_object($raw) && method_exists($raw, 'all')) { return $raw->all(); } return []; } /** * پارامترهای برگشت Montify باید همگی string باشند (طبق مستند). * * @param array $query * @return array */ private function normalizeReturnQuery(array $query): array { $normalized = []; foreach ($query as $key => $value) { if (!is_string($key) || $key === '') { continue; } if (is_array($value)) { continue; } if ($value === null) { continue; } $normalized[$key] = (string) $value; } return $normalized; } /** * منبع حقیقت مستقل از redirect: استعلام مستقیم وضعیت از Montify. * * @return array{status: string, paid: bool} */ public function syncStatusByReference(string $referenceId): array { if ($referenceId === '' || !$this->isConfigured()) { return ['status' => 'unknown', 'paid' => false]; } $path = '/external-payment/status'; $headers = $this->signer()->signRequest('GET', $path, null); try { $response = Http::withHeaders($headers) ->acceptJson() ->timeout(20) ->get($this->baseUrl() . $path, ['reference_id' => $referenceId]); } catch (\Throwable $e) { report($e); return ['status' => 'unknown', 'paid' => false]; } $data = $response->json(); if (!$response->successful() || !is_array($data)) { \Log::warning('montify.status_failed', [ 'reference_id' => $referenceId, 'http_status' => $response->status(), 'body' => is_array($data) ? $data : $response->body(), ]); return ['status' => 'unknown', 'paid' => false]; } $status = strtolower((string) ($data['status'] ?? '')); if ($status !== 'paid') { return ['status' => $status !== '' ? $status : 'unknown', 'paid' => false]; } $withdraw = $this->creditor->findByReference($referenceId); if (!$withdraw) { return ['status' => 'paid', 'paid' => false]; } $amount = (int) ($data['amount'] ?? 0); if ($amount > 0 && (int) $withdraw->cash !== $amount) { \Log::warning('montify.status_amount_mismatch', [ 'reference_id' => $referenceId, 'remote_amount' => $amount, 'local_amount' => (int) $withdraw->cash, ]); return ['status' => 'amount_mismatch', 'paid' => false]; } $trackNumber = (string) ($data['track_number'] ?? ''); $this->creditor->markPaidAndCredit( $withdraw, $trackNumber !== '' ? $trackNumber : (string) ($data['token'] ?? ''), 'واریز درگاه Montify' ); return ['status' => 'paid', 'paid' => true]; } public function handleWebhook(Request $request): array { $body = $request->all(); $signer = $this->signer(); if (!$signer->verifyWebhookSignature($body, $request->header('X-Signature'))) { return ['ok' => false, 'http_status' => 401, 'message' => 'invalid_signature']; } $clientId = (string) $request->header('X-Client-Id', ''); if ($clientId !== '' && $clientId !== $this->clientId()) { return ['ok' => false, 'http_status' => 401, 'message' => 'invalid_client']; } if (!$signer->isTimestampFresh($body['timestamp'] ?? $request->header('X-Timestamp'))) { return ['ok' => false, 'http_status' => 401, 'message' => 'expired_timestamp']; } $status = (string) ($body['status'] ?? ''); if ($status !== 'success') { return ['ok' => true, 'ignored' => true]; } $referenceId = (string) ($body['reference_id'] ?? ''); $amount = (int) ($body['amount'] ?? 0); $trackNumber = (string) ($body['track_number'] ?? ''); $sessionId = (string) ($body['session_id'] ?? ''); $withdraw = $this->creditor->findByReference($referenceId); if (!$withdraw) { return ['ok' => false, 'http_status' => 404, 'message' => 'not_found']; } if ($amount > 0 && (int) $withdraw->cash !== $amount) { return ['ok' => false, 'http_status' => 422, 'message' => 'amount_mismatch']; } $this->creditor->markPaidAndCredit( $withdraw, $trackNumber !== '' ? $trackNumber : $sessionId, 'واریز درگاه Montify' ); return ['ok' => true]; } private function baseUrl(): string { return rtrim($this->config->getString('external_payment_base_url', ''), '/'); } private function clientId(): string { return $this->config->getString('external_payment_client_id', ''); } private function clientSecret(): string { return $this->config->getString('external_payment_client_secret', ''); } private function gatewayName(): string { return strtolower($this->config->getString('external_payment_gateway', 'zarinpall')); } private function signer(): MontifyHmacSigner { return new MontifyHmacSigner($this->clientId(), $this->clientSecret()); } }