diff --git a/app/Console/Commands/MontifyCheckReturnCommand.php b/app/Console/Commands/MontifyCheckReturnCommand.php new file mode 100644 index 0000000..42dd98b --- /dev/null +++ b/app/Console/Commands/MontifyCheckReturnCommand.php @@ -0,0 +1,100 @@ +argument('url'); + $queryString = str_contains($input, '?') ? substr($input, strpos($input, '?') + 1) : $input; + + parse_str($queryString, $query); + if (!is_array($query) || $query === []) { + $this->error('پارامتری در ورودی پیدا نشد.'); + + return self::FAILURE; + } + + $clientId = $config->getString('external_payment_client_id', ''); + $clientSecret = $config->getString('external_payment_client_secret', ''); + if ($clientSecret === '') { + $this->error('external_payment_client_secret در تنظیمات خالی است.'); + + return self::FAILURE; + } + + $signature = strtolower((string) ($query['signature'] ?? '')); + unset($query['signature']); + + $strings = []; + foreach ($query as $key => $value) { + if (is_array($value)) { + continue; + } + $strings[$key] = (string) $value; + } + $withoutEmpty = array_filter($strings, static fn ($value) => $value !== ''); + + $signer = new MontifyHmacSigner($clientId, $clientSecret); + $castNumeric = static function (array $payload) { + foreach (['amount', 'timestamp'] as $key) { + if (isset($payload[$key]) && ctype_digit((string) $payload[$key])) { + $payload[$key] = (int) $payload[$key]; + } + } + + return $payload; + }; + + $variants = [ + 'strings' => $strings, + 'strings_without_empty' => $withoutEmpty, + 'numeric' => $castNumeric($strings), + 'numeric_without_empty' => $castNumeric($withoutEmpty), + ]; + + $this->line(''); + $this->info('امضای دریافتی: ' . ($signature !== '' ? $signature : '— (خالی)')); + $this->line('طول client_secret: ' . strlen($clientSecret) . ' کاراکتر'); + $this->line(''); + + $matched = null; + foreach ($variants as $name => $payload) { + $canonical = $signer->stableStringify($payload); + $expected = hash_hmac('sha256', $canonical, $clientSecret); + $ok = $signature !== '' && hash_equals($expected, $signature); + if ($ok && $matched === null) { + $matched = $name; + } + + $this->line(($ok ? '[OK] ' : '[خطا] ') . $name); + $this->line(' canonical: ' . $canonical); + $this->line(' expected : ' . $expected); + $this->line(''); + } + + if ($matched !== null) { + $this->info('امضا با قالب «' . $matched . '» می‌خواند.'); + + return self::SUCCESS; + } + + $this->error('هیچ قالبی با امضا نخواند — احتمالاً client_secret اشتباه است یا پارامتری از URL حذف شده.'); + + return self::FAILURE; + } +} diff --git a/app/Http/Controllers/Admin/SettingsController.php b/app/Http/Controllers/Admin/SettingsController.php index 4f312ec..14096b0 100644 --- a/app/Http/Controllers/Admin/SettingsController.php +++ b/app/Http/Controllers/Admin/SettingsController.php @@ -30,25 +30,25 @@ class SettingsController extends Controller ->orderBy('key'); if ($request->boolean('system_only')) { - return response()->json( - $query - ->where(function ($q) { - $q->where('can_edit', 0)->orWhere('can_edit', false); - }) - ->where(function ($q) { - $q->where('is_active', 1) - ->orWhere('is_active', true) - ->orWhere('is_active', '1'); - }) - ->get() - ); + $rows = $query + ->where(function ($q) { + $q->where('can_edit', 0)->orWhere('can_edit', false); + }) + ->where(function ($q) { + $q->where('is_active', 1) + ->orWhere('is_active', true) + ->orWhere('is_active', '1'); + }) + ->get(); + + return response()->json($this->authorizeSettingsPayload($rows)); } if ($request->boolean('editable_only')) { - return response()->json($query->where('can_edit', 1)->get()); + return response()->json($this->authorizeSettingsPayload($query->where('can_edit', 1)->get())); } - return response()->json($query->get()); + return response()->json($this->authorizeSettingsPayload($query->get())); } public function edit(Request $request) @@ -134,4 +134,20 @@ class SettingsController extends Controller { return in_array($key, self::PANEL_MANAGED_KEYS, true); } + + /** + * اپ کاربر (بدون auth ادمین) هرگز secret/credential نمی‌بیند. + * پنل ادمین با auth:user همه کلیدها را می‌بیند. + * + * @param \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Collection|iterable $rows + * @return \Illuminate\Support\Collection + */ + private function authorizeSettingsPayload($rows) + { + if (auth('user')->check()) { + return collect($rows)->values(); + } + + return Setting::forPublicApi($rows); + } } diff --git a/app/Http/Controllers/pay/WalletPaymentController.php b/app/Http/Controllers/pay/WalletPaymentController.php new file mode 100644 index 0000000..d624423 --- /dev/null +++ b/app/Http/Controllers/pay/WalletPaymentController.php @@ -0,0 +1,285 @@ +json($this->payments->publicOptions()); + } + + public function initiate(Request $request) + { + $gateway = $this->payments->resolve(); + if (!$gateway || !$gateway->isAvailable()) { + throw new UnprocessableEntityHttpException('پرداخت آنلاین در حال حاضر فعال نیست'); + } + + $amount = (int) $request->input('value', $request->input('amount', 0)); + $minDeposit = $this->config->getInt('min_deposit', 0); + if ($amount < 1000) { + throw new UnprocessableEntityHttpException('حداقل مبلغ پرداخت ۱۰۰۰ ریال است'); + } + if ($minDeposit > 0 && $amount < $minDeposit) { + throw new UnprocessableEntityHttpException( + 'حداقل مقدار واریز ' . number_format($minDeposit) . ' ریال است' + ); + } + + $customer = auth('customer')->user(); + $withdraw = Withdraw::create([ + 'customer_id' => $customer->id, + 'cash' => $amount, + 'note' => 'واریز آنلاین', + 'sts' => 1, + ]); + + $referenceId = 'WD-' . $withdraw->id; + + try { + $result = $gateway->initiate([ + 'amount' => $amount, + 'reference_id' => $referenceId, + 'description' => 'شارژ کیف پول کاربر ' . ($customer->name ?? $customer->id), + 'metadata' => [ + 'withdraw_id' => $withdraw->id, + 'customer_id' => $customer->id, + ], + ]); + } catch (RuntimeException $e) { + $withdraw->sts = 3; + $withdraw->save(); + + \Log::warning('wallet.payment.initiate_failed', [ + 'withdraw_id' => $withdraw->id, + 'message' => $e->getMessage(), + ]); + + return response()->json([ + 'message' => $e->getMessage() ?: 'مشکل در ارتباط با درگاه', + ], 422); + } + + if (!empty($result['session_id'])) { + $withdraw->tracked_number = (string) $result['session_id']; + $withdraw->save(); + } + + // حداقل اطلاعات برای redirect — بدون credential و بدون جزئیات داخلی درگاه + return response()->json([ + 'payment_url' => $result['payment_url'], + ], 201); + } + + /** + * وریفای برگشت مرورگر از فرانت — امضا و شارژ سمت سرور؛ بدون افشای secret. + * Body یا query: status, reference_id, session_id, amount, track_number, timestamp, signature + */ + public function verifyReturn(Request $request) + { + $gateway = $this->payments->resolve(); + if (!$gateway) { + throw new UnprocessableEntityHttpException('پرداخت آنلاین فعال نیست'); + } + + // همه پارامترهای برگشت عیناً وارد امضا می‌شوند؛ whitelist کردن باعث + // مغایرت امضا می‌شود اگر پارتنر پارامتر اضافه‌ای بفرستد. + $payload = []; + foreach ($request->all() as $key => $value) { + if (!is_string($key) || $key === '' || is_array($value) || $value === null) { + continue; + } + if (count($payload) >= 30) { + break; + } + $payload[$key] = is_bool($value) ? ($value ? 'true' : 'false') : (string) $value; + } + + if (($payload['signature'] ?? '') === '' || ($payload['reference_id'] ?? '') === '') { + return response()->json([ + 'ok' => false, + 'message' => 'پارامترهای بازگشت ناقص است', + ], 422); + } + + $result = $gateway->handleReturnPayload($payload); + + if (!($result['ok'] ?? false)) { + // redirect مرورگر قابل اعتماد نیست؛ منبع حقیقت، استعلام مستقیم از درگاه است. + $fallback = $this->statusFallback((string) $payload['reference_id']); + if ($fallback) { + return $fallback; + } + + return response()->json([ + 'ok' => false, + 'message' => $result['message'] ?? 'خطا در تأیید پرداخت', + ], (int) ($result['http_status'] ?? 400)); + } + + /** @var Withdraw|null $withdraw */ + $withdraw = $result['withdraw'] ?? null; + if (!$withdraw) { + return response()->json(['ok' => false, 'message' => 'سفارش یافت نشد'], 404); + } + + if (!$this->ownsWithdraw($withdraw)) { + return response()->json(['ok' => false, 'message' => 'دسترسی غیرمجاز'], 403); + } + + $paid = (int) $withdraw->sts === 2; + + return $this->paymentStateResponse( + $withdraw, + $paid ? 'success' : ((string) ($payload['status'] ?? '') === 'success' ? 'pending' : 'failed') + ); + } + + /** + * استعلام وضعیت یک پرداخت از درگاه — برای polling فرانت وقتی نتیجه هنوز قطعی نیست. + */ + public function status(Request $request) + { + $referenceId = trim((string) $request->input('reference_id', '')); + if ($referenceId === '') { + return response()->json(['ok' => false, 'message' => 'شناسه پرداخت ارسال نشده'], 422); + } + + $response = $this->statusFallback($referenceId); + if ($response) { + return $response; + } + + return response()->json(['ok' => false, 'message' => 'سفارش یافت نشد'], 404); + } + + /** + * استعلام مستقیم وضعیت از درگاه با بررسی مالکیت سفارش. + */ + private function statusFallback(string $referenceId): ?\Illuminate\Http\JsonResponse + { + $withdraw = $this->creditor->findByReference($referenceId); + if (!$withdraw || !$this->ownsWithdraw($withdraw)) { + return null; + } + + if ((int) $withdraw->sts !== 2) { + $gateway = $this->payments->resolve(); + if ($gateway instanceof SupportsStatusQuery) { + $gateway->syncStatusByReference($referenceId); + $withdraw = $withdraw->fresh() ?: $withdraw; + } + } + + $sts = (int) $withdraw->sts; + + return $this->paymentStateResponse( + $withdraw, + match ($sts) { + 2 => 'success', + 3 => 'failed', + default => 'pending', + } + ); + } + + private function ownsWithdraw(Withdraw $withdraw): bool + { + return (int) $withdraw->customer_id === (int) auth('customer')->id(); + } + + private function paymentStateResponse(Withdraw $withdraw, string $status): \Illuminate\Http\JsonResponse + { + return response()->json([ + 'ok' => true, + 'paid' => (int) $withdraw->sts === 2, + 'status' => $status, + 'amount' => (int) $withdraw->cash, + 'reference_id' => 'WD-' . $withdraw->id, + 'withdraw_id' => $withdraw->id, + 'track_number' => $withdraw->tracked_number, + ]); + } + + public function returnFromGateway(Request $request, string $provider) + { + try { + $gateway = $this->payments->resolveOrFail($provider); + } catch (InvalidArgumentException $e) { + return response('درگاه نامعتبر', 404); + } + + $result = $gateway->handleReturn($request); + if (!($result['ok'] ?? false)) { + return response( + (string) ($result['message'] ?? 'خطا'), + (int) ($result['http_status'] ?? 400) + ); + } + + $Withdraw = $result['withdraw']; + $site_url = $this->config->resolvePublicSiteUrl($request) ?: '/'; + + return view('showTransactionReport', compact('Withdraw', 'site_url')); + } + + public function webhook(Request $request, string $provider) + { + try { + $gateway = $this->payments->resolveOrFail($provider); + } catch (InvalidArgumentException $e) { + return response()->json(['message' => 'invalid_provider'], 404); + } + + $result = $gateway->handleWebhook($request); + $status = (int) ($result['http_status'] ?? 200); + + if (!($result['ok'] ?? false)) { + return response()->json([ + 'message' => $result['message'] ?? 'error', + ], $status >= 400 ? $status : 400); + } + + return response()->json([ + 'ok' => true, + 'ignored' => (bool) ($result['ignored'] ?? false), + ]); + } + + /** سازگاری با URLهای قبلی external-payment/* → montify */ + public function returnLegacy(Request $request) + { + return $this->returnFromGateway($request, WalletPaymentManager::PROVIDER_MONTIFY); + } + + public function webhookLegacy(Request $request) + { + return $this->webhook($request, WalletPaymentManager::PROVIDER_MONTIFY); + } +} diff --git a/app/Models/Setting.php b/app/Models/Setting.php index d2ab187..497fc5c 100644 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -42,6 +42,65 @@ class Setting extends Model return $cols; } + /** + * کلیدهایی که هرگز نباید در پاسخ عمومی api/get_settings برگردند. + * credential درگاه، توکن‌ها، رمزها و پیکربندی داخلی پرداخت. + */ + public static function isSecretOrServerOnlyKey(string $key): bool + { + $key = strtolower(trim($key)); + if ($key === '') { + return false; + } + + // کل گروه پیکربندی پرداخت آنلاین — فقط سرور / ادمین + if ( + str_starts_with($key, 'external_payment_') + || $key === 'wallet_payment_provider' + || str_starts_with($key, 'payping_') + ) { + return true; + } + + $exact = [ + 'node_client_id', + 'node_shared_secret', + 'montzar_password', + 'montzar_token', + 'montzar_mobile', + 'bale_bot_token', + 'telegram_bot_token', + 'rubika_bot_token', + 'tahesab_token', + 'tahesab_host', + 'tahesab_port', + 'tahesab_db', + 'pargar_password', + 'pargar_api_token', + 'pargar_username', + 'zarin_api', + 'zarin_token', + ]; + if (in_array($key, $exact, true)) { + return true; + } + + return (bool) preg_match('/(^|_)(secret|password|token|api_key|apikey|private)(_|$)/', $key); + } + + /** + * فیلتر تنظیمات برای پاسخ عمومی (اپ کاربر). + * + * @param \Illuminate\Support\Collection|iterable $settings + * @return \Illuminate\Support\Collection + */ + public static function forPublicApi($settings) + { + return collect($settings) + ->filter(fn ($row) => ! static::isSecretOrServerOnlyKey((string) (is_array($row) ? ($row['key'] ?? '') : ($row->key ?? '')))) + ->values(); + } + protected $fillable = [ 'key', 'title_fa', diff --git a/app/Services/Payment/Contracts/SupportsStatusQuery.php b/app/Services/Payment/Contracts/SupportsStatusQuery.php new file mode 100644 index 0000000..981eec5 --- /dev/null +++ b/app/Services/Payment/Contracts/SupportsStatusQuery.php @@ -0,0 +1,17 @@ + $rawQuery + * @return array{ok: bool, withdraw: ?Withdraw, http_status?: int, message?: string} + */ + public function handleReturnPayload(array $rawQuery): array; + + /** + * پردازش webhook. + * + * @return array{ok: bool, ignored?: bool, http_status?: int, message?: string} + */ + public function handleWebhook(Request $request): array; +} diff --git a/app/Services/Payment/Gateways/MontifyPartnerGateway.php b/app/Services/Payment/Gateways/MontifyPartnerGateway.php new file mode 100644 index 0000000..b3213d6 --- /dev/null +++ b/app/Services/Payment/Gateways/MontifyPartnerGateway.php @@ -0,0 +1,407 @@ +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()); + } +} diff --git a/app/Services/Payment/Support/MontifyHmacSigner.php b/app/Services/Payment/Support/MontifyHmacSigner.php new file mode 100644 index 0000000..46e0ea8 --- /dev/null +++ b/app/Services/Payment/Support/MontifyHmacSigner.php @@ -0,0 +1,202 @@ +|null $body + * @return array + */ + public function signRequest(string $method, string $path, ?array $body): array + { + $timestamp = (string) time(); + $nonce = (string) Str::uuid(); + $bodyString = $body !== null ? $this->stableStringify($body) : ''; + $bodyHash = hash('sha256', $bodyString); + $canonical = implode("\n", [ + strtoupper($method), + $path, + $bodyHash, + $timestamp, + $nonce, + ]); + $signature = hash_hmac('sha256', $canonical, $this->clientSecret); + + return [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + 'X-Client-Id' => $this->clientId, + 'X-Timestamp' => $timestamp, + 'X-Nonce' => $nonce, + 'X-Signature' => $signature, + ]; + } + + /** + * @param array $query + */ + public function verifyReturnSignature(array $query): bool + { + return $this->matchReturnSignatureVariant($query) !== null; + } + + /** + * نام قالبی که امضا با آن خواند، یا null اگر هیچ‌کدام نخواند. + * + * پارتنر ممکن است مقادیر عددی را به‌صورت number امضا کند یا پارامتر خالی + * (مثل track_number) را اصلاً در payload نگذارد؛ همه حالت‌ها بررسی می‌شود. + * + * @param array $query + */ + public function matchReturnSignatureVariant(array $query): ?string + { + $signature = (string) ($query['signature'] ?? ''); + if ($signature === '' || !ctype_xdigit($signature)) { + return null; + } + + unset($query['signature']); + + foreach ($this->payloadVariants($query) as $name => $payload) { + $expected = hash_hmac('sha256', $this->stableStringify($payload), $this->clientSecret); + if ($this->hashEqualsHex($signature, $expected)) { + return $name; + } + } + + return null; + } + + /** + * @param array $body + */ + public function verifyWebhookSignature(array $body, ?string $signatureHeader): bool + { + $signature = (string) ($signatureHeader ?? ''); + if ($signature === '' || !ctype_xdigit($signature)) { + return false; + } + + // بدنه همان‌طور که آمده (نوع‌ها از JSON) اولویت دارد، بعد حالت‌های نرمال‌شده + $candidates = array_merge( + ['raw' => $body], + $this->payloadVariants($body) + ); + + foreach ($candidates as $payload) { + $expected = hash_hmac('sha256', $this->stableStringify($payload), $this->clientSecret); + if ($this->hashEqualsHex($signature, $expected)) { + return true; + } + } + + return false; + } + + /** + * حالت‌های ممکن payload برای مقایسه امضا (بدون تکرار). + * + * @param array $payload + * @return array> + */ + private function payloadVariants(array $payload): array + { + $strings = []; + foreach ($payload as $key => $value) { + if (!is_string($key) || $key === '' || is_array($value) || $value === null) { + continue; + } + $strings[$key] = is_bool($value) ? ($value ? 'true' : 'false') : (string) $value; + } + + $withoutEmpty = array_filter($strings, static fn ($value) => $value !== ''); + + $variants = [ + 'strings' => $strings, + 'strings_without_empty' => $withoutEmpty, + 'numeric' => $this->castNumericValues($strings), + 'numeric_without_empty' => $this->castNumericValues($withoutEmpty), + ]; + + $unique = []; + $seen = []; + foreach ($variants as $name => $variant) { + $fingerprint = $this->stableStringify($variant); + if (isset($seen[$fingerprint])) { + continue; + } + $seen[$fingerprint] = true; + $unique[$name] = $variant; + } + + return $unique; + } + + /** + * @param array $payload + * @return array + */ + private function castNumericValues(array $payload): array + { + foreach (['amount', 'timestamp'] as $key) { + if (isset($payload[$key]) && is_string($payload[$key]) && ctype_digit($payload[$key])) { + $payload[$key] = (int) $payload[$key]; + } + } + + return $payload; + } + + public function isTimestampFresh(int|string|null $timestamp, int $skewSeconds = 300): bool + { + if ($timestamp === null || $timestamp === '') { + return false; + } + + return abs(time() - (int) $timestamp) <= $skewSeconds; + } + + /** + * @param mixed $value + */ + public function stableStringify($value): string + { + if ($value === null || !is_array($value)) { + return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + if (array_is_list($value)) { + $parts = array_map(fn ($item) => $this->stableStringify($item), $value); + + return '[' . implode(',', $parts) . ']'; + } + + ksort($value); + $parts = []; + foreach ($value as $key => $item) { + $parts[] = '"' . $key . '":' . $this->stableStringify($item); + } + + return '{' . implode(',', $parts) . '}'; + } + + private function hashEqualsHex(string $a, string $b): bool + { + if (!ctype_xdigit($a) || !ctype_xdigit($b) || strlen($a) !== strlen($b)) { + return false; + } + + return hash_equals(strtolower($a), strtolower($b)); + } +} diff --git a/app/Services/Payment/WalletDepositCreditor.php b/app/Services/Payment/WalletDepositCreditor.php new file mode 100644 index 0000000..4d0c9fa --- /dev/null +++ b/app/Services/Payment/WalletDepositCreditor.php @@ -0,0 +1,93 @@ +find((int) $matches[1]); + } + + return Withdraw::with('customer') + ->where('tracked_number', $referenceId) + ->orderByDesc('id') + ->first(); + } + + public function markPaidAndCredit(Withdraw $withdraw, string $trackNumber, string $note = 'واریز درگاه'): void + { + DB::transaction(function () use ($withdraw, $trackNumber, $note) { + $locked = Withdraw::query()->whereKey($withdraw->id)->lockForUpdate()->first(); + if (!$locked || (int) $locked->sts === 2) { + return; + } + + if ($trackNumber !== '') { + $locked->tracked_number = $trackNumber; + } + $locked->sts = 2; + $locked->save(); + + $user = Customer::find($locked->customer_id); + if (!$user) { + return; + } + + $wallet = new WalletInterface($user->id, $user->type); + new TransactionInterface($wallet, [ + 'cash' => (int) $locked->cash, + 'expire_at' => now(), + 'withdraw_id' => $locked->id, + 'active' => true, + ], 3, false, true); + + try { + app(\App\Services\PargarSyncService::class)->queueWalletDocument( + $user, + 'cash', + (float) $locked->cash, + [ + 'withdraw_id' => $locked->id, + 'note' => $note, + 'source' => 'gateway', + ] + ); + } catch (\Throwable $e) { + report($e); + } + + $locked->loadMissing('customer'); + $message = 'کاربر ' . ($locked->customer->name ?? '') . ' با شماره موبایل ' . ($locked->customer->mobile ?? '') + . ' حساب خود را به مقدار ' . number_format((int) $locked->cash) . ' ریال شارژ کرد. با تشکر، ' + . $this->config->getString('name_fa', ''); + + User::where('send_sms', 1)->get()->each(function ($admin) use ($message) { + if (Controller::isSmsNotifEnabled('sms_notif_gateway_charge_admin')) { + Controller::Send($admin->mobile, $message); + } + }); + }); + } +} diff --git a/app/Services/Payment/WalletPaymentManager.php b/app/Services/Payment/WalletPaymentManager.php new file mode 100644 index 0000000..31f16a1 --- /dev/null +++ b/app/Services/Payment/WalletPaymentManager.php @@ -0,0 +1,97 @@ +> + */ + public function drivers(): array + { + return [ + self::PROVIDER_MONTIFY => MontifyPartnerGateway::class, + // مثال آینده: + // self::PROVIDER_PAYPING => PaypingDirectGateway::class, + ]; + } + + public function configuredProvider(): string + { + $provider = strtolower($this->config->getString('wallet_payment_provider', '')); + if ($provider !== '' && $provider !== self::PROVIDER_NONE) { + return $provider; + } + + // سازگاری با سوییچ قدیمی external_payment_enabled + if ($this->config->getBool('external_payment_enabled', false)) { + return self::PROVIDER_MONTIFY; + } + + return self::PROVIDER_NONE; + } + + public function resolve(?string $provider = null): ?WalletPaymentGatewayInterface + { + $key = strtolower($provider ?: $this->configuredProvider()); + if ($key === '' || $key === self::PROVIDER_NONE) { + return null; + } + + $drivers = $this->drivers(); + if (!isset($drivers[$key])) { + return null; + } + + return app($drivers[$key]); + } + + public function resolveOrFail(?string $provider = null): WalletPaymentGatewayInterface + { + $gateway = $this->resolve($provider); + if (!$gateway) { + throw new InvalidArgumentException('درگاه پرداخت پیکربندی نشده است'); + } + + return $gateway; + } + + /** فقط برای UI کلاینت — بدون افشای نوع/credential */ + public function isOnlineDepositAvailable(): bool + { + $gateway = $this->resolve(); + + return $gateway !== null && $gateway->isAvailable(); + } + + /** + * @return array{online_enabled: bool} + */ + public function publicOptions(): array + { + return [ + 'online_enabled' => $this->isOnlineDepositAvailable(), + ]; + } +} diff --git a/database/migrations/2026_07_24_000001_add_external_payment_settings.php b/database/migrations/2026_07_24_000001_add_external_payment_settings.php new file mode 100644 index 0000000..684a950 --- /dev/null +++ b/database/migrations/2026_07_24_000001_add_external_payment_settings.php @@ -0,0 +1,101 @@ + 'external_payment_enabled', + 'value' => 'false', + 'title_fa' => 'شارژ آنلاین کیف پول (درگاه خارجی)', + 'type' => 'switch', + 'can_edit' => 1, + 'is_active' => 1, + 'description' => 'روشن: دکمه پرداخت آنلاین در راهنمای شارژ کیف پول نمایش داده می‌شود و اتصال به درگاه خارجی فعال است. خاموش: فقط واریز دستی.', + ], + [ + 'key' => 'external_payment_base_url', + 'value' => '', + 'title_fa' => 'آدرس API درگاه خارجی', + 'type' => 'text', + 'can_edit' => 0, + 'is_active' => 1, + 'description' => 'BASE_URL سرویس Montify (بدون اسلش انتهایی)، مثلاً https://node.example.com', + ], + [ + 'key' => 'external_payment_client_id', + 'value' => '', + 'title_fa' => 'شناسه کلاینت درگاه خارجی', + 'type' => 'text', + 'can_edit' => 0, + 'is_active' => 1, + 'description' => 'client_id دریافتی از ادمین Montify', + ], + [ + 'key' => 'external_payment_client_secret', + 'value' => '', + 'title_fa' => 'رمز کلاینت درگاه خارجی', + 'type' => 'text', + 'can_edit' => 0, + 'is_active' => 1, + 'description' => 'client_secret دریافتی از ادمین Montify. فقط روی سرور نگه دارید؛ در فرانت استفاده نشود.', + ], + [ + 'key' => 'external_payment_gateway', + 'value' => 'zarinpall', + 'title_fa' => 'نام درگاه خارجی', + 'type' => 'select', + 'can_edit' => 0, + 'is_active' => 1, + 'description' => 'یکی از درگاه‌های فعال در Montify|zarinpall,zibal,payping,sepal', + ], + [ + 'key' => 'external_payment_return_url', + 'value' => '', + 'title_fa' => 'آدرس برگشت از درگاه', + 'type' => 'text', + 'can_edit' => 0, + 'is_active' => 1, + 'description' => 'return_url ثبت‌شده در whitelist پارتنر. خالی = آدرس پیش‌فرض سرور (/customer/wallet/external-payment/return)', + ], + [ + 'key' => 'external_payment_webhook_url', + 'value' => '', + 'title_fa' => 'آدرس Webhook درگاه خارجی', + 'type' => 'text', + 'can_edit' => 0, + 'is_active' => 1, + 'description' => 'webhook_url برای اطلاع سرور از پرداخت موفق. خالی = آدرس پیش‌فرض سرور (/customer/wallet/external-payment/webhook)', + ], + ]; + + foreach ($rows as $row) { + if (DB::table('settings')->where('key', $row['key'])->exists()) { + continue; + } + + DB::table('settings')->insert(array_merge($row, [ + 'created_at' => $now, + 'updated_at' => $now, + ])); + } + } + + public function down(): void + { + DB::table('settings')->whereIn('key', [ + 'external_payment_enabled', + 'external_payment_base_url', + 'external_payment_client_id', + 'external_payment_client_secret', + 'external_payment_gateway', + 'external_payment_return_url', + 'external_payment_webhook_url', + ])->delete(); + } +}; diff --git a/database/migrations/2026_07_24_000002_add_wallet_payment_provider_setting.php b/database/migrations/2026_07_24_000002_add_wallet_payment_provider_setting.php new file mode 100644 index 0000000..7b9c913 --- /dev/null +++ b/database/migrations/2026_07_24_000002_add_wallet_payment_provider_setting.php @@ -0,0 +1,34 @@ +where('key', 'wallet_payment_provider')->exists()) { + $legacyEnabled = DB::table('settings') + ->where('key', 'external_payment_enabled') + ->value('value'); + $default = filter_var((string) $legacyEnabled, FILTER_VALIDATE_BOOLEAN) ? 'montify' : 'none'; + + DB::table('settings')->insert([ + 'key' => 'wallet_payment_provider', + 'value' => $default, + 'title_fa' => 'درگاه شارژ آنلاین کیف پول', + 'type' => 'select', + 'can_edit' => 0, + 'is_active' => 1, + 'description' => 'انتخاب درگاه فعال برای شارژ آنلاین. none=غیرفعال، montify=پارتنر Montify. درگاه‌های مستقیم بعدی اینجا اضافه می‌شوند.|none,montify', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + + public function down(): void + { + DB::table('settings')->where('key', 'wallet_payment_provider')->delete(); + } +}; diff --git a/database/migrations/2026_07_26_000001_expose_gateway_settings_in_admin_panel.php b/database/migrations/2026_07_26_000001_expose_gateway_settings_in_admin_panel.php new file mode 100644 index 0000000..1725594 --- /dev/null +++ b/database/migrations/2026_07_26_000001_expose_gateway_settings_in_admin_panel.php @@ -0,0 +1,117 @@ + 'wallet_payment_provider', + 'value' => 'none', + 'title_fa' => 'نوع درگاه شارژ آنلاین', + 'type' => 'select', + 'description' => 'none=خاموش، montify=پارتنر Montify|none,montify', + ], + [ + 'key' => 'external_payment_enabled', + 'value' => 'false', + 'title_fa' => 'فعال‌سازی شارژ آنلاین', + 'type' => 'switch', + 'description' => 'روشن: در صورت پیکربندی کامل، دکمه پرداخت آنلاین در اپ نمایش داده می‌شود. خاموش: فقط واریز دستی.', + ], + [ + 'key' => 'external_payment_base_url', + 'value' => '', + 'title_fa' => 'آدرس API درگاه (BASE_URL)', + 'type' => 'text', + 'description' => 'آدرس پایه API Montify بدون اسلش انتهایی، مثلاً https://node.example.com', + ], + [ + 'key' => 'external_payment_client_id', + 'value' => '', + 'title_fa' => 'شناسه کلاینت (client_id)', + 'type' => 'text', + 'description' => 'client_id دریافتی از ادمین Montify', + ], + [ + 'key' => 'external_payment_client_secret', + 'value' => '', + 'title_fa' => 'رمز کلاینت (client_secret)', + 'type' => 'text', + 'description' => 'client_secret دریافتی از ادمین Montify — فقط روی سرور بماند', + ], + [ + 'key' => 'external_payment_gateway', + 'value' => 'zarinpall', + 'title_fa' => 'نام درگاه داخل Montify', + 'type' => 'select', + 'description' => 'یکی از درگاه‌های فعال در Montify|zarinpall,zibal,payping,sepal', + ], + [ + 'key' => 'external_payment_return_url', + 'value' => '', + 'title_fa' => 'آدرس برگشت (return_url)', + 'type' => 'text', + 'description' => 'اختیاری. خالی = پیش‌فرض سرور /customer/wallet/payment/return/montify', + ], + [ + 'key' => 'external_payment_webhook_url', + 'value' => '', + 'title_fa' => 'آدرس Webhook', + 'type' => 'text', + 'description' => 'اختیاری. خالی = پیش‌فرض سرور /customer/wallet/payment/webhook/montify', + ], + ]; + + foreach ($rows as $row) { + $existing = DB::table('settings')->where('key', $row['key'])->first(); + if ($existing) { + DB::table('settings')->where('key', $row['key'])->update([ + 'can_edit' => 1, + 'is_active' => 1, + 'title_fa' => $row['title_fa'], + 'type' => $row['type'], + 'description' => $row['description'], + 'updated_at' => $now, + ]); + continue; + } + + DB::table('settings')->insert([ + 'key' => $row['key'], + 'value' => $row['value'], + 'title_fa' => $row['title_fa'], + 'type' => $row['type'], + 'can_edit' => 1, + 'is_active' => 1, + 'description' => $row['description'], + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + public function down(): void + { + DB::table('settings')->whereIn('key', [ + 'wallet_payment_provider', + 'external_payment_base_url', + 'external_payment_client_id', + 'external_payment_client_secret', + 'external_payment_gateway', + 'external_payment_return_url', + 'external_payment_webhook_url', + ])->update([ + 'can_edit' => 0, + 'updated_at' => now(), + ]); + } +}; diff --git a/database/migrations/2026_07_27_000001_widen_withdraws_tracked_number.php b/database/migrations/2026_07_27_000001_widen_withdraws_tracked_number.php new file mode 100644 index 0000000..ac51b1b --- /dev/null +++ b/database/migrations/2026_07_27_000001_widen_withdraws_tracked_number.php @@ -0,0 +1,29 @@ + + +{ + "amount": 500000, + "reference_id": "ORDER-1001", + "gateway": "zarinpall", + "return_url": "https://yoursite.com/payment/result", + "webhook_url": "https://yoursite.com/api/payment/webhook", + "description": "پرداخت سفارش ۱۰۰۱" +} +``` + +**پاسخ موفق `201`:** + +```json +{ + "session_id": "a1b2c3...", + "payment_url": "https://shop.example.com/pay/external/a1b2c3...", + "expires_at": "2026-07-21T10:15:00.000Z", + "reference_id": "ORDER-1001" +} +``` + +کاربر را به `payment_url` هدایت کنید. جلسه حدود **۱۵ دقیقه** اعتبار دارد. + +**خطاهای رایج:** + +| error | معنی | +|-------|------| +| `invalid_signature` | امضا اشتباه است | +| `expired_timestamp` | ساعت سرور نامعتبر | +| `replay_detected` | nonce تکراری | +| `invalid_return_url` | `return_url` در whitelist نیست | +| `invalid_gateway` | درگاه نامعتبر | +| `amount_too_low` | مبلغ کمتر از ۱۰۰۰ ریال | +| `duplicate_reference` | `reference_id` قبلاً در وضعیت فعال استفاده شده | +| `gateway_not_configured` | درگاه در Montify پیکربندی نشده | + +--- + +### ۴.۲. استعلام وضعیت — `GET /external-payment/status` + +**Auth:** HMAC (الزامی) + +یکی از queryها لازم است: + +- `?reference_id=ORDER-1001` +- `?session_id=` + +**پاسخ نمونه:** + +```json +{ + "token": "a1b2c3...", + "reference_id": "ORDER-1001", + "amount": 500000, + "gateway": "zarinpall", + "status": "paid", + "track_number": "123456789", + "expires_at": "...", + "paid_at": "...", + "created_at": "..." +} +``` + +**وضعیت‌ها (`status`):** + +| status | معنی | +|--------|------| +| `pending` | ساخته شده، هنوز به درگاه نرفته | +| `processing` | به درگاه هدایت شده | +| `paid` | پرداخت موفق | +| `failed` | ناموفق | +| `expired` | منقضی | +| `cancelled` | لغو | + +> منبع حقیقت نهایی برای علامت‌گذاری سفارش: **Webhook** یا **استعلام status**. فقط به redirect مرورگر اکتفا نکنید. + +--- + +## ۵. برگشت کاربر (Browser Redirect) + +بعد از پرداخت، Montify کاربر را به `return_url` شما می‌فرستد با queryهای امضاشده: + +``` +https://yoursite.com/payment/result? + status=success + &reference_id=ORDER-1001 + &session_id=a1b2c3... + &amount=500000 + &track_number=123456789 + ×tamp=1720000123 + &signature= +``` + +| پارامتر | توضیح | +|---------|--------| +| `status` | `success` یا `failed` | +| `reference_id` | همان شناسه شما | +| `session_id` | توکن جلسه | +| `amount` | مبلغ (ریال) | +| `track_number` | کد پیگیری درگاه (ممکن است خالی باشد) | +| `timestamp` | زمان امضا | +| `signature` | HMAC روی payload | + +### تأیید امضای redirect + +1. همه پارامترها به‌جز `signature` را بگیرید. +2. یک object بسازید (مقادیر string هستند). +3. `stableStringify` کنید. +4. `HMAC-SHA256(..., client_secret)` بگیرید و با `signature` مقایسه کنید. +5. `timestamp` را چک کنید (±۵ دقیقه توصیه می‌شود). +6. مبلغ و `reference_id` را با سفارش خودتان تطبیق دهید. + +--- + +## ۶. Webhook سرور به سرور + +Montify بعد از پرداخت موفق یک `POST` به `webhook_url` می‌زند. + +**Headers:** + +``` +Content-Type: application/json +X-Client-Id: ptr_xxxxxxxx +X-Timestamp: 1720000123 +X-Signature: +``` + +**Body:** + +```json +{ + "status": "success", + "reference_id": "ORDER-1001", + "session_id": "a1b2c3...", + "amount": 500000, + "track_number": "123456789", + "gateway": "zarinpall", + "timestamp": "1720000123" +} +``` + +### تأیید امضای webhook + +`X-Signature = HMAC-SHA256(stableStringify(body), client_secret)` + +پاسخ `2xx` برگردانید. در غیر این صورت ممکن است ارسال ناموفق ثبت شود. + +> Webhook فقط وقتی session به `paid` برسد ارسال می‌شود. + +--- + +## ۷. نمونه کد Node.js + +```js +const crypto = require('crypto'); +const axios = require('axios'); + +const BASE_URL = 'https://node.example.com'; +const CLIENT_ID = process.env.MONTIFY_CLIENT_ID; +const CLIENT_SECRET = process.env.MONTIFY_CLIENT_SECRET; + +function stableStringify(obj) { + if (obj === null || typeof obj !== 'object') return JSON.stringify(obj); + if (Array.isArray(obj)) return `[${obj.map(stableStringify).join(',')}]`; + const keys = Object.keys(obj).sort(); + return `{${keys.map((k) => `"${k}":${stableStringify(obj[k])}`).join(',')}}`; +} + +function signRequest(method, path, body) { + const timestamp = Math.floor(Date.now() / 1000).toString(); + const nonce = crypto.randomUUID(); + const bodyString = body ? stableStringify(body) : ''; + const bodyHash = crypto.createHash('sha256').update(bodyString).digest('hex'); + const canonical = [method.toUpperCase(), path, bodyHash, timestamp, nonce].join('\n'); + const signature = crypto.createHmac('sha256', CLIENT_SECRET).update(canonical).digest('hex'); + return { + 'Content-Type': 'application/json', + 'X-Client-Id': CLIENT_ID, + 'X-Timestamp': timestamp, + 'X-Nonce': nonce, + 'X-Signature': signature, + }; +} + +async function initiatePayment({ amount, referenceId, gateway, returnUrl, webhookUrl, description }) { + const path = '/external-payment/initiate'; + const body = { + amount, + reference_id: referenceId, + gateway, + return_url: returnUrl, + webhook_url: webhookUrl, + description, + }; + const headers = signRequest('POST', path, body); + const { data } = await axios.post(`${BASE_URL}${path}`, body, { headers }); + return data; // { session_id, payment_url, expires_at, reference_id } +} + +function verifyReturnSignature(query) { + const { signature, ...rest } = query; + const expected = crypto + .createHmac('sha256', CLIENT_SECRET) + .update(stableStringify(rest)) + .digest('hex'); + return crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex')); +} + +function verifyWebhook(body, signatureHeader) { + const expected = crypto + .createHmac('sha256', CLIENT_SECRET) + .update(stableStringify(body)) + .digest('hex'); + const a = Buffer.from(signatureHeader, 'hex'); + const b = Buffer.from(expected, 'hex'); + return a.length === b.length && crypto.timingSafeEqual(a, b); +} + +// مثال استفاده: +// const result = await initiatePayment({ +// amount: 500000, +// referenceId: 'ORDER-1001', +// gateway: 'zarinpall', +// returnUrl: 'https://yoursite.com/payment/result', +// webhookUrl: 'https://yoursite.com/api/payment/webhook', +// description: 'خرید سفارش', +// }); +// res.redirect(result.payment_url); +``` + +--- + +## ۸. نمونه کد PHP + +```php + $v) { + $parts[] = '"' . $k . '":' . stable_stringify($v); + } + return '{' . implode(',', $parts) . '}'; +} + +function sign_request(string $method, string $path, ?array $body, string $clientId, string $secret): array { + $timestamp = (string) time(); + $nonce = bin2hex(random_bytes(16)); + $bodyString = $body ? stable_stringify($body) : ''; + $bodyHash = hash('sha256', $bodyString); + $canonical = strtoupper($method) . "\n" . $path . "\n" . $bodyHash . "\n" . $timestamp . "\n" . $nonce; + $signature = hash_hmac('sha256', $canonical, $secret); + + return [ + 'Content-Type: application/json', + 'X-Client-Id: ' . $clientId, + 'X-Timestamp: ' . $timestamp, + 'X-Nonce: ' . $nonce, + 'X-Signature: ' . $signature, + ]; +} + +function initiate_payment(array $payload, string $baseUrl, string $clientId, string $secret): array { + $path = '/external-payment/initiate'; + $headers = sign_request('POST', $path, $payload, $clientId, $secret); + $ch = curl_init(rtrim($baseUrl, '/') . $path); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + CURLOPT_RETURNTRANSFER => true, + ]); + $raw = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + $data = json_decode($raw, true); + if ($code >= 400) { + throw new RuntimeException($data['message'] ?? 'initiate failed'); + } + return $data; +} + +function verify_return_signature(array $query, string $secret): bool { + $signature = $query['signature'] ?? ''; + unset($query['signature']); + $expected = hash_hmac('sha256', stable_stringify($query), $secret); + return hash_equals($expected, $signature); +} +``` + +--- + +## ۹. چک‌لیست پیاده‌سازی سایت شما + +1. `client_id` / `client_secret` را فقط در env سرور نگه دارید. +2. قبل از پرداخت، سفارش خود را با وضعیت «در انتظار پرداخت» ذخیره کنید. +3. از سرور `initiate` بزنید و کاربر را به `payment_url` بفرستید. +4. صفحه `return_url` را بسازید و signature را verify کنید. +5. endpoint webhook بسازید و signature را verify کنید؛ سفارش را آنجا `paid` کنید. +6. برای اطمینان، در صورت نیاز `GET /status` هم بزنید (idempotent). +7. `reference_id` را یکتا و قابل ردیابی نگه دارید. +8. `return_url` را دقیقاً زیر پیشوند whitelist ثبت‌شده بفرستید. + +--- + +## ۱۰. امنیت — خلاصه قوانین + +| کار درست | کار غلط | +|----------|---------| +| initiate فقط از سرور | ساخت لینک پرداخت در مرورگر کاربر | +| امضا با `client_secret` | گذاشتن secret در JS فرانت | +| verify امضای redirect و webhook | اعتماد کور به query بدون signature | +| تطبیق `amount` با سفارش | قبول هر مبلغ برگشتی | +| whitelist برای `return_url` | قبول هر URL دلخواه | + +--- + +## ۱۱. پشتیبانی + +در صورت خطای مداوم `invalid_signature`: + +1. ساعت سرور (NTP) را چک کنید. +2. `stableStringify` و ترتیب کلیدها را بررسی کنید. +3. `PATH` دقیقاً `/external-payment/initiate` باشد (بدون domain و بدون slash اضافه). +4. body ارسالی دقیقاً همان چیزی باشد که hash شده (نه serialize متفاوت axios/curl). + +برای فعال‌سازی partner جدید یا افزودن دامنه به whitelist با ادمین Montify هماهنگ کنید. diff --git a/docs/WALLET_ONLINE_PAYMENT.md b/docs/WALLET_ONLINE_PAYMENT.md new file mode 100644 index 0000000..5fbd475 --- /dev/null +++ b/docs/WALLET_ONLINE_PAYMENT.md @@ -0,0 +1,298 @@ +# شارژ آنلاین کیف پول (Wallet Online Payment) + +مستند پیاده‌سازی داخلی پروژه برای شارژ کیف پول از طریق درگاه. +برای پروتکل Montify (HMAC، initiate، webhook) به سند جداگانه مراجعه کنید: + +→ [`EXTERNAL_PAYMENT_PARTNER.md`](./EXTERNAL_PAYMENT_PARTNER.md) + +--- + +## ۱. هدف + +- کاربر بتواند علاوه بر واریز دستی (کارت/شبا)، از **پرداخت آنلاین** کیف را شارژ کند. +- تمام credentialها و انتخاب درگاه **فقط سمت سرور** بماند. +- معماری طوری باشد که بعداً چند نوع درگاه (Montify پارتنر، درگاه مستقیم، …) اضافه شود. + +--- + +## ۲. جریان کلی (از دید کاربر و سیستم) + +``` +اپ کاربر (khaneghi_user) + │ مبلغ را وارد می‌کند → مدال راهنمای شارژ + │ GET /customer/wallet/payment/options → { online_enabled } + │ POST /customer/wallet/payment/initiate → { payment_url } + ▼ +سرور ما + │ Withdraw (sts=1) می‌سازد + │ درگاه فعال را از Settings انتخاب می‌کند (مثلاً montify) + │ با HMAC به Montify: POST /external-payment/initiate + ▼ +Montify → payment_url → درگاه بانکی + │ + ├─► Browser return → GET /customer/wallet/payment/return/{provider} + └─► Webhook → POST /customer/wallet/payment/webhook/{provider} + │ + └─► شارژ idempotent کیف (sts=2 + Transaction) +``` + +**منبع حقیقت شارژ:** webhook (یا verify امن سمت سرور). فقط به query برگشت مرورگر اعتماد نکنید؛ امضا را verify کنید و مبلغ/`reference_id` را با `Withdraw` تطبیق دهید. + +--- + +## ۳. امنیت — قوانین اجباری + +| درست | غلط | +|------|-----| +| `client_id` / `client_secret` / `base_url` فقط در Settings سرور | گذاشتن secret در JS فرانت | +| فیلتر `api/get_settings` برای کلیدهای محرمانه | برگرداندن `external_payment_*` به اپ کاربر | +| اپ فقط `online_enabled` و بعد `payment_url` می‌بیند | خواندن provider یا credential از settings در کلاینت | +| initiate فقط با JWT مشتری | ساخت لینک پرداخت در مرورگر | +| verify امضای return و webhook | اعتماد کور به query بدون signature | +| تطبیق `amount` با `Withdraw.cash` | قبول هر مبلغ برگشتی | + +### ۳.۱. چه چیزی به اپ کاربر می‌رسد؟ + +- `GET /customer/wallet/payment/options` → فقط `{ "online_enabled": true|false }` +- `POST /customer/wallet/payment/initiate` → فقط `{ "payment_url": "https://..." }` +- **هیچ** `client_id`، `client_secret`، `base_url`، نام درگاه داخلی، یا `wallet_payment_provider` در Network تب اپ نباید دیده شود. + +### ۳.۲. فیلتر `get_settings` + +در `App\Models\Setting::isSecretOrServerOnlyKey()` و `SettingsController::authorizeSettingsPayload()`: + +- درخواست عمومی (`api/get_settings` بدون auth ادمین) → کلیدهای محرمانه حذف می‌شوند. +- پنل ادمین (`admin/get_settings` با `auth:user`) → همه کلیدها قابل مشاهده/ویرایش طبق `can_edit`. + +کلیدهای مرتبط با پرداخت که به اپ عمومی نمی‌روند شامل: + +- همه `external_payment_*` +- `wallet_payment_provider` +- همه `payping_*` +- الگوهای `*_secret` / `*_token` / `*_password` و لیست ثابت توکن‌ها + +--- + +## ۴. Settings (جدول `settings`) + +پیکربندی از پنل ادمین → گروه **تنظیمات درگاه** (`can_edit=1`). + +| کلید | نوع | توضیح | +|------|-----|--------| +| `wallet_payment_provider` | select | `none` = خاموش، `montify` = پارتنر Montify | +| `external_payment_enabled` | switch | فعال‌سازی شارژ آنلاین (همراه با پیکربندی کامل) | +| `external_payment_base_url` | text | BASE_URL API Montify (بدون اسلش انتهایی) | +| `external_payment_client_id` | text | `client_id` پارتنر | +| `external_payment_client_secret` | text | `client_secret` — محرمانه، فقط سرور | +| `external_payment_gateway` | select | `zarinpall` / `zibal` / `payping` / `sepal` | +| `external_payment_return_url` | text | اختیاری؛ خالی = پیش‌فرض سرور | +| `external_payment_webhook_url` | text | اختیاری؛ خالی = پیش‌فرض سرور | +| `min_deposit` | price | حداقل مبلغ واریز (گروه کارمزد و محدودیت‌ها) | + +### مقادیر پیشنهادی برای abaiegold + +| فیلد Settings | مقدار | +|---------------|--------| +| `external_payment_return_url` | `https://abaeigold.ir/wallet/payment-result` | +| `external_payment_webhook_url` | `https://abaeigold.ir/server/customer/wallet/payment/webhook/montify` | + +در whitelist پارتنر Montify حداقل این پیشوندها را ثبت کنید: + +- Return: `https://abaeigold.ir/wallet/payment-result` یا `https://abaeigold.ir/wallet/` +- Webhook: همان URL کامل webhook بالا + +جریان برگشت فرانت: + +1. Montify کاربر را به `return_url` با query امضاشده می‌فرستد +2. صفحه `khaneghi_user` → `/wallet/payment-result` +3. فرانت `POST /customer/wallet/payment/verify-return` می‌زند (امضا فقط سمت سرور verify می‌شود) +4. webhook هم موازی برای شارژ قطعی به بک‌اند می‌آید (idempotent) + + +### Migrationها + +- `2026_07_24_000001_add_external_payment_settings.php` +- `2026_07_24_000002_add_wallet_payment_provider_setting.php` +- `2026_07_26_000001_expose_gateway_settings_in_admin_panel.php` + +```bash +php artisan migrate +``` + +--- + +## ۵. APIهای سرور ما + +پیشوند: `/customer` +Auth مشتری: `auth:customer` + `customer.active` (به‌جز return/webhook) + +| متد | مسیر | Auth | نقش | +|-----|------|------|-----| +| GET | `/wallet/payment/options` | مشتری | `{ online_enabled }` | +| POST | `/wallet/payment/initiate` | مشتری | Body: `{ value }` (ریال) → `{ payment_url }` | +| POST | `/wallet/payment/verify-return` | مشتری | verify امضای بازگشت + شارژ | +| GET | `/wallet/payment/status` | مشتری | `?reference_id=WD-123` → استعلام مستقیم از درگاه | +| GET | `/wallet/payment/return/{provider}` | عمومی | برگشت مرورگر؛ verify + گزارش | +| POST | `/wallet/payment/webhook/{provider}` | عمومی (HMAC) | شارژ کیف | + +### verify-return و status + +- فرانت **همه** پارامترهای query صفحه بازگشت را بدون فیلتر می‌فرستد؛ حذف حتی یک پارامتر امضا را می‌شکند. +- سرور امضا را با چند قالب payload می‌سنجد (مقادیر string، بدون مقادیر خالی، `amount`/`timestamp` عددی) تا اختلاف‌های جزئی پیاده‌سازی پارتنر باعث رد شدن پرداخت واقعی نشود. دستکاری مقادیر همچنان رد می‌شود. +- اگر امضا نخواند یا نتیجه هنوز قطعی نباشد، سرور `GET /external-payment/status` را از Montify می‌پرسد (`SupportsStatusQuery`) و در صورت `paid` کیف را idempotent شارژ می‌کند. redirect مرورگر هیچ‌وقت منبع حقیقت نیست. +- مالکیت `Withdraw` قبل از هر پاسخی با کاربر لاگین‌شده تطبیق داده می‌شود. + +### سازگاری قدیمی + +| مسیر قدیمی | معادل | +|------------|--------| +| `POST /wallet/external-payment/initiate` | همان `initiate` | +| `GET /wallet/external-payment/return` | `return/montify` | +| `POST /wallet/external-payment/webhook` | `webhook/montify` | + +### Initiate — جزئیات + +1. بررسی `WalletPaymentManager::isOnlineDepositAvailable()` +2. اعتبارسنجی مبلغ (≥ ۱۰۰۰ و ≥ `min_deposit`) +3. ساخت `Withdraw` با `sts=1`، `note=واریز آنلاین` +4. `reference_id = WD-{withdraw_id}` +5. فراخوانی `gateway->initiate(...)` +6. ذخیره `session_id` در `tracked_number` در صورت وجود +7. پاسخ فقط `payment_url` + +### Webhook / Return — شارژ + +منطق مشترک در `App\Services\Payment\WalletDepositCreditor`: + +- قفل ردیف `Withdraw` (idempotent) +- اگر قبلاً `sts=2` باشد، دوباره شارژ نمی‌کند +- `TransactionInterface` نوع واریز نقدی +- همگام‌سازی حسابداری (Pargar) در صورت امکان +- SMS ادمین با سوییچ `sms_notif_gateway_charge_admin` + +--- + +## ۶. معماری کد (چنددرگاهی) + +``` +app/Services/Payment/ + Contracts/ + WalletPaymentGatewayInterface.php # قرارداد درگاه + Gateways/ + MontifyPartnerGateway.php # پیاده‌سازی فعلی Montify + # PaypingDirectGateway.php # آینده + Support/ + MontifyHmacSigner.php # HMAC + stableStringify + WalletPaymentManager.php # انتخاب درگاه از Settings + WalletDepositCreditor.php # شارژ idempotent کیف + +app/Http/Controllers/pay/ + WalletPaymentController.php # options / initiate / return / webhook +``` + +### قرارداد درگاه + +هر درگاه باید این‌ها را پیاده کند: + +- `key(): string` — مثلاً `montify` +- `isAvailable(): bool` — فعال + پیکربندی کامل +- `initiate(array $payload): array` — حداقل `payment_url` +- `returnUrl()` / `webhookUrl()` +- `handleReturn(Request): array` +- `handleWebhook(Request): array` + +### افزودن درگاه جدید (چک‌لیست) + +1. کلاس جدید در `Gateways/` با `WalletPaymentGatewayInterface` +2. ثبت در `WalletPaymentManager::drivers()` +3. افزودن مقدار به optionsی `wallet_payment_provider` در migration/admin (`settingTypes.ts`) +4. Settings مخصوص آن درگاه (و اضافه کردن به فیلتر secret در `Setting::isSecretOrServerOnlyKey`) +5. مسیرهای `return/{provider}` و `webhook/{provider}` از قبل generic هستند +6. تست: options → initiate → webhook → موجودی کیف + +--- + +## ۷. فرانت‌اند (khaneghi_user) + +| فایل | نقش | +|------|-----| +| `src/api/wallet.ts` | `getWalletPaymentOptions` / `initiateWalletPayment` | +| `src/views/base/wallet/composables/useWalletPage.ts` | بارگذاری `online_enabled`، `payOnline()` | +| `src/views/base/wallet/components/WalletDepositGuideModal.vue` | دکمه «پرداخت آنلاین از درگاه» | +| `src/views/base/wallet/index.vue` | اتصال props/events مدال | + +رفتار UI: + +1. کاربر مبلغ را وارد می‌کند و واریز می‌زند → مدال راهنما (کارت/شبا + پشتیبانی) +2. اگر `online_enabled` باشد دکمه درگاه نمایش داده می‌شود +3. کلیک → `initiate` → `window.location = payment_url` + +**نکته:** وضعیت دکمه از endpoint سرور می‌آید، نه از `settingsStore` و کلیدهای `external_payment_*`. + +--- + +## ۸. پنل ادمین + +- گروه تنظیمات: `admin/.../configuration/settingGroups.ts` → id `external_payment` +- انواع/برچسب/قفل: `settingTypes.ts` +- ویرایش credentialها فقط در ادمین (و کلیدهای `can_edit=0` فقط دیپلوی) + +--- + +## ۹. مدل داده مرتبط + +| مدل | نقش | +|-----|-----| +| `Withdraw` | درخواست واریز؛ `sts`: 1=در انتظار، 2=موفق، 3=ناموفق | +| `reference_id` | `WD-{id}` برای Montify | +| Wallet `TransactionInterface` | واریز نقدی پس از موفقیت | + +واریز دستی تغییری نکرده؛ فقط راهنما + کپی کارت/شبا از Settings عمومی (`deposit_card_number` و …) است. + +--- + +## ۱۰. راه‌اندازی عملیاتی + +1. `php artisan migrate` +2. در پنل ادمین / دیپلوی: + - `wallet_payment_provider` = `montify` + - `external_payment_base_url` / `client_id` / `client_secret` / `gateway` +3. در پنل Montify: + - ثبت `allowed_return_urls` و `webhook_url` مطابق URLهای سرور +4. تست با مبلغ ≥ `min_deposit` +5. تأیید: Network اپ کاربر فاقد secret باشد؛ فقط `options` و `initiate` دیده شود + +--- + +## ۱۱. عیب‌یابی سریع + +| مشکل | بررسی | +|------|--------| +| دکمه درگاه نیست | `options.online_enabled`؛ provider؛ کامل بودن credential؛ `isAvailable()` | +| `مشکل در ارتباط با درگاه` | ساعت سرور (NTP)، `stableStringify`، PATH دقیق، body خام همان hash | +| شارژ دوبل | باید idempotent باشد (`sts=2` + lock) | +| secret در Network | مسیر باید `api/get_settings` باشد نه admin؛ فیلتر `Setting::forPublicApi` | +| `امضای نامعتبر` در verify-return | `php artisan montify:check-return "<کل URL بازگشت>"` را روی سرور بزنید؛ خروجی نشان می‌دهد کدام قالب می‌خواند یا اینکه `client_secret` غلط است. لاگ `montify.return_invalid_signature` هم کلیدهای دریافتی را دارد | +| پرداخت موفق ولی کیف شارژ نشد | `GET /wallet/payment/status?reference_id=WD-…`؛ لاگ `montify.status_failed` و رسیدن webhook را بررسی کنید | + +جزئیات امضای HMAC و خطاهای Montify: [`EXTERNAL_PAYMENT_PARTNER.md`](./EXTERNAL_PAYMENT_PARTNER.md) + +--- + +## ۱۲. ارجاع فایل‌های کلیدی + +``` +server/docs/WALLET_ONLINE_PAYMENT.md ← این سند +server/docs/EXTERNAL_PAYMENT_PARTNER.md ← پروتکل Montify +server/app/Services/Payment/... +server/app/Http/Controllers/pay/WalletPaymentController.php +server/app/Console/Commands/MontifyCheckReturnCommand.php ← عیب‌یابی امضای بازگشت +server/routes/customer.php +server/app/Models/Setting.php ← فیلتر secret +server/database/migrations/2026_07_24_000001_add_external_payment_settings.php +server/database/migrations/2026_07_24_000002_add_wallet_payment_provider_setting.php +khaneghi_user/src/api/wallet.ts +khaneghi_user/src/views/base/wallet/... +admin/src/pages/menuPages/configuration/settingTypes.ts +admin/src/pages/menuPages/configuration/settingGroups.ts +``` diff --git a/routes/customer.php b/routes/customer.php index 8e4a374..521692f 100644 --- a/routes/customer.php +++ b/routes/customer.php @@ -10,6 +10,7 @@ use Illuminate\Support\Facades\Route; use App\Http\Controllers\Single\BankAccountController; use \App\Http\Controllers\Customer\FundController; use \App\Http\Controllers\pay\paypingController; +use App\Http\Controllers\pay\WalletPaymentController; use \App\Http\Controllers\Single\SingleOrderController; use App\Http\Controllers\Admin\ProductController as AdminProductController; use App\Http\Controllers\Customer\ProductController; @@ -28,6 +29,13 @@ Route::get('/TCharchee ', [paypingController::class, 'TCharchee']); Route::post('/charge_account_verify', [paypingController::class, 'verifyTransation']); Route::post('/charge_account_verify/{id}', [paypingController::class, 'verifyTransationByID']); +//----------wallet online payment — return/webhook per provider (public) +Route::get('/wallet/payment/return/{provider}', [WalletPaymentController::class, 'returnFromGateway']); +Route::post('/wallet/payment/webhook/{provider}', [WalletPaymentController::class, 'webhook']); +// سازگاری با URLهای قبلی Montify +Route::get('/wallet/external-payment/return', [WalletPaymentController::class, 'returnLegacy']); +Route::post('/wallet/external-payment/webhook', [WalletPaymentController::class, 'webhookLegacy']); + $isStore = app(ConfigService::class)->isStore(); Route::middleware('validation')->group(function () { @@ -75,6 +83,12 @@ Route::middleware(['auth:customer', 'customer.active'])->group(function () use ( //-----gateway Route::post('/charge_account ', [paypingController::class, 'startTransation']); + Route::get('/wallet/payment/options', [WalletPaymentController::class, 'options']); + Route::post('/wallet/payment/initiate', [WalletPaymentController::class, 'initiate']); + Route::post('/wallet/payment/verify-return', [WalletPaymentController::class, 'verifyReturn']); + Route::get('/wallet/payment/status', [WalletPaymentController::class, 'status']); + // سازگاری قدیمی + Route::post('/wallet/external-payment/initiate', [WalletPaymentController::class, 'initiate']); //--------------Customer