diff --git a/04_Laravel/app/Http/Controllers/Api/MockGatewayController.php b/04_Laravel/app/Http/Controllers/Api/MockGatewayController.php new file mode 100644 index 0000000..58400b3 --- /dev/null +++ b/04_Laravel/app/Http/Controllers/Api/MockGatewayController.php @@ -0,0 +1,44 @@ +input('authority'); + $amount = $request->input('amount', 0); + + return response()->view('mock-gateway', [ + 'authority' => $authority, + 'amount' => $amount, + ]); + } + + public function simulateSuccess(Request $request) + { + $authority = $request->input('authority'); + + return redirect()->to( + url('/api/v1/payment/callback') . '?' . http_build_query([ + 'Authority' => $authority, + 'Status' => 'OK', + ]) + ); + } + + public function simulateFailure(Request $request) + { + $authority = $request->input('authority'); + + return redirect()->to( + url('/api/v1/payment/callback') . '?' . http_build_query([ + 'Authority' => $authority, + 'Status' => 'NOK', + ]) + ); + } +} \ No newline at end of file diff --git a/04_Laravel/app/Http/Controllers/Api/PaymentController.php b/04_Laravel/app/Http/Controllers/Api/PaymentController.php index 0ee3918..fe8bea7 100644 --- a/04_Laravel/app/Http/Controllers/Api/PaymentController.php +++ b/04_Laravel/app/Http/Controllers/Api/PaymentController.php @@ -4,14 +4,12 @@ namespace App\Http\Controllers\Api; use App\Enums\PaymentGateway; use App\Enums\TransactionStatus; -use App\Enums\TransactionType; use App\Http\Controllers\Controller; -use App\Models\User; use App\Models\Wallet; -use App\Models\WalletActivityLog; use App\Models\WalletTransaction; use App\Services\WalletService; use App\Services\ZarinpalService; +use App\Services\MockZarinpalService; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -21,12 +19,21 @@ class PaymentController extends Controller { public function __construct( protected WalletService $walletService, - protected ZarinpalService $zarinpalService + protected ZarinpalService $zarinpalService, + protected MockZarinpalService $mockZarinpalService ) {} - /** - * ارسال کاربر به درگاه پرداخت - */ + private function isMockMode(): bool + { + return config('ifnex.zarinpal.merchant_id') === 'fake-merchant-id-for-testing' + || config('ifnex.zarinpal.sandbox', true); + } + + private function getGatewayService() + { + return $this->isMockMode() ? $this->mockZarinpalService : $this->zarinpalService; + } + public function redirectToGateway(Request $request): JsonResponse { $user = $request->user(); @@ -36,9 +43,9 @@ class PaymentController extends Controller } $validated = $request->validate([ - 'amount' => ['required', 'numeric', 'min:10000'], // حداقل ۱۰ هزار ریال + 'amount' => ['required', 'numeric', 'min:10000'], 'description' => ['nullable', 'string', 'max:500'], - 'frontend_callback' => ['nullable', 'url', 'max:500'], // URL فرانت‌اند برای redirect بعد از پرداخت + 'frontend_callback' => ['nullable', 'url', 'max:500'], ]); $wallet = $user->wallet ?? Wallet::create([ @@ -46,15 +53,13 @@ class PaymentController extends Controller 'balance' => 0, ]); - // بررسی مسدود نبودن کیف پول if ($wallet->isFrozen()) { return response()->json([ - 'message' => 'کیف پول شما مسدود است. لطفاً با پشتیبانی تماس بگیرید.', + 'message' => 'کیف پول شما مسدود است.', ], 403); } try { - // ایجاد تراکنش pending $transaction = $this->walletService->requestDeposit( wallet: $wallet, amount: $validated['amount'], @@ -63,18 +68,18 @@ class PaymentController extends Controller gateway: PaymentGateway::ZARINPAL ); - // دریافت لینک پرداخت از زرین‌پال - $paymentData = $this->zarinpalService->requestPayment( + $gateway = $this->getGatewayService(); + $paymentData = $gateway->requestPayment( amount: $validated['amount'], description: $validated['description'] ?? 'شارژ کیف پول IFNEX', mobile: $user->phone, email: $user->email ); - // ذخیره authority و frontend_callback در metadata $metadata = $transaction->metadata ?? []; $metadata['zarinpal_authority'] = $paymentData['authority']; $metadata['frontend_callback'] = $validated['frontend_callback'] ?? null; + $metadata['is_mock'] = $this->isMockMode(); $transaction->update(['metadata' => $metadata]); return response()->json([ @@ -84,6 +89,7 @@ class PaymentController extends Controller 'transaction_id' => $transaction->id, 'authority' => $paymentData['authority'], 'amount' => $paymentData['amount'], + 'is_mock' => $this->isMockMode(), ]); } catch (\Exception $e) { @@ -99,95 +105,145 @@ class PaymentController extends Controller } } - /** - * Callback از درگاه پرداخت (بعد از بازگشت کاربر) - */ public function callback(Request $request): RedirectResponse { $authority = $request->input('Authority'); - $status = $request->input('Status'); // OK یا NOK + $status = $request->input('Status'); - // پیدا کردن تراکنش بر اساس authority - $transaction = WalletTransaction::whereJsonContains('metadata->zarinpal_authority', $authority)->first(); + Log::info('Payment callback received', [ + 'authority' => $authority, + 'status' => $status, + ]); + // پیدا کردن تراکنش با JSON_EXTRACT + $transaction = WalletTransaction::whereRaw( + "JSON_EXTRACT(metadata, '$.zarinpal_authority') = ?", + [$authority] + )->first(); + + // Fallback برای MySQL قدیمی‌تر if (!$transaction) { - Log::warning('Payment callback: transaction not found', ['authority' => $authority]); - return redirect(config('ifnex.zarinpal.frontend_failure_url', '/payment/failed')); + $transaction = WalletTransaction::where('metadata->zarinpal_authority', $authority)->first(); } - $frontendCallback = $transaction->metadata['frontend_callback'] ?? config('ifnex.zarinpal.frontend_failure_url', '/payment/failed'); + if (!$transaction) { + Log::warning('Payment callback: transaction not found', [ + 'authority' => $authority, + ]); + + return redirect(config('ifnex.zarinpal.frontend_failure_url', '/') . '?' . http_build_query([ + 'status' => 'error', + 'message' => 'تراکنش یافت نشد', + ])); + } + + // ✅ اصلاح: تعریف $frontendCallback در ابتدای متد (قبل از هر استفاده) + $frontendCallback = $transaction->metadata['frontend_callback'] + ?? config('ifnex.zarinpal.frontend_failure_url', '/'); + + // بررسی اینکه تراکنش قبلاً پردازش نشده باشد + if ($transaction->status !== TransactionStatus::PENDING) { + Log::info('Transaction already processed', [ + 'transaction_id' => $transaction->id, + 'current_status' => $transaction->status->value, + ]); + + if ($transaction->status === TransactionStatus::COMPLETED) { + return redirect($frontendCallback . '?' . http_build_query([ + 'status' => 'success', + 'transaction_id' => $transaction->id, + 'ref_id' => $transaction->gateway_reference_id ?? '', + 'amount' => $transaction->amount, + 'message' => 'این تراکنش قبلاً پردازش شده است', + ])); + } + + return redirect($frontendCallback . '?' . http_build_query([ + 'status' => 'failed', + 'transaction_id' => $transaction->id, + 'message' => 'این تراکنش قبلاً ناموفق شده است', + ])); + } // کاربر پرداخت را لغو کرده if ($status !== 'OK') { - $this->walletService->failTransaction($transaction, 'کاربر پرداخت را لغو کرد یا پرداخت ناموفق بود.'); + $this->walletService->failTransaction($transaction, 'کاربر پرداخت را لغو کرد.'); - $failureUrl = $frontendCallback . '?' . http_build_query([ + return redirect($frontendCallback . '?' . http_build_query([ 'status' => 'failed', 'transaction_id' => $transaction->id, 'message' => 'پرداخت ناموفق بود', - ]); - - return redirect($failureUrl); + ])); } + // تایید پرداخت با درگاه try { - // بررسی صحت پرداخت - $verification = $this->zarinpalService->verifyPayment( + Log::info('Verifying payment', [ + 'transaction_id' => $transaction->id, + 'authority' => $authority, + 'current_status' => $transaction->status->value, + ]); + + $gateway = $this->getGatewayService(); + $verification = $gateway->verifyPayment( authority: $authority, expectedAmount: $transaction->amount ); + Log::info('Payment verification result', [ + 'transaction_id' => $transaction->id, + 'verification' => $verification, + ]); + if (!$verification['success']) { $this->walletService->failTransaction( $transaction, - 'تأیید درگاه ناموفق بود: ' . ($verification['error_message'] ?? 'نامشخص') + 'تأیید درگاه ناموفق: ' . ($verification['error_message'] ?? 'نامشخص') ); - $failureUrl = $frontendCallback . '?' . http_build_query([ + return redirect($frontendCallback . '?' . http_build_query([ 'status' => 'failed', 'transaction_id' => $transaction->id, 'message' => 'تأیید پرداخت ناموفق بود', - ]); - - return redirect($failureUrl); + ])); } - // تأیید تراکنش و افزایش موجودی $this->walletService->completeDeposit( transaction: $transaction, gatewayReferenceId: $verification['ref_id'] ?? $authority ); - $successUrl = config('ifnex.zarinpal.frontend_success_url', '/payment/success') . '?' . http_build_query([ + Log::info('Payment completed successfully', [ + 'transaction_id' => $transaction->id, + 'amount' => $transaction->amount, + ]); + + return redirect($frontendCallback . '?' . http_build_query([ 'status' => 'success', 'transaction_id' => $transaction->id, 'ref_id' => $verification['ref_id'] ?? '', 'amount' => $transaction->amount, - ]); - - return redirect($successUrl); + ])); } catch (\Exception $e) { Log::error('Payment callback error', [ 'transaction_id' => $transaction->id, 'error' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => $e->getTraceAsString(), ]); $this->walletService->failTransaction($transaction, 'خطای سیستمی: ' . $e->getMessage()); - $failureUrl = $frontendCallback . '?' . http_build_query([ + return redirect($frontendCallback . '?' . http_build_query([ 'status' => 'failed', 'transaction_id' => $transaction->id, - 'message' => 'خطای سیستمی در پردازش پرداخت', - ]); - - return redirect($failureUrl); + 'message' => 'خطای سیستمی: ' . $e->getMessage(), + ])); } } - /** - * استعلام وضعیت یک پرداخت - */ public function checkStatus(Request $request, $transactionId): JsonResponse { $user = $request->user(); diff --git a/04_Laravel/app/Services/MockZarinpalService.php b/04_Laravel/app/Services/MockZarinpalService.php new file mode 100644 index 0000000..2ce35e7 --- /dev/null +++ b/04_Laravel/app/Services/MockZarinpalService.php @@ -0,0 +1,52 @@ + $amount, + 'authority' => $authority, + ]); + + return [ + 'authority' => $authority, + 'payment_url' => url("/api/v1/payment/mock-gateway?authority={$authority}&amount={$amount}"), + 'amount' => $amount, + 'amount_toman' => (int) round($amount / 10), + ]; + } + + public function verifyPayment(string $authority, float $expectedAmount): array + { + Log::info('Mock Zarinpal Payment Verification', [ + 'authority' => $authority, + ]); + + if (str_starts_with($authority, 'MOCK_')) { + return [ + 'success' => true, + 'ref_id' => 'MOCK_REF_' . Str::upper(Str::random(16)), + 'card_hash' => 'MOCK_HASH_' . rand(100000, 999999), + 'card_pan' => '6037****' . rand(1000, 9999), + ]; + } + + return [ + 'success' => false, + 'error_code' => -1, + 'error_message' => 'Authority نامعتبر است', + ]; + } +} \ No newline at end of file diff --git a/04_Laravel/app/Services/ZarinpalService.php b/04_Laravel/app/Services/ZarinpalService.php index 3b4ddaa..ef7d19a 100644 --- a/04_Laravel/app/Services/ZarinpalService.php +++ b/04_Laravel/app/Services/ZarinpalService.php @@ -2,7 +2,6 @@ namespace App\Services; -use App\Models\WalletTransaction; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -10,31 +9,29 @@ class ZarinpalService { private string $merchantId; private string $baseUrl; - private string $callbackUrl; + private ?string $callbackUrl; public function __construct() { $this->merchantId = config('ifnex.zarinpal.merchant_id', 'fake-merchant-id-for-testing'); - $isProduction = config('ifnex.zarinpal.sandbox', true); + $isSandbox = config('ifnex.zarinpal.sandbox', true); - // URLs برای محیط تست و واقعی - $this->baseUrl = $isProduction + $this->baseUrl = $isSandbox ? 'https://sandbox.zarinpal.com/pg/v4' : 'https://api.zarinpal.com/pg/v4'; - $this->callbackUrl = config('ifnex.zarinpal.callback_url', url('/api/v1/payment/callback')); + // ✅ اصلاح: مدیریت null و تنظیم callback + $this->callbackUrl = config('ifnex.zarinpal.callback_url') + ?? url('/api/v1/payment/callback'); } - /** - * ایجاد درخواست پرداخت (Payment Request) - */ public function requestPayment( float $amount, string $description, ?string $mobile = null, ?string $email = null ): array { - $amountToman = (int) round($amount / 10); // ریال به تومان + $amountToman = (int) round($amount / 10); $response = Http::timeout(30)->post("{$this->baseUrl}/PaymentRequest.json", [ 'merchant_id' => $this->merchantId, @@ -55,8 +52,8 @@ class ZarinpalService 'response' => $result, ]); - if ($result['data']['code'] !== 100) { - throw new \Exception('خطا در ارتباط با زرین‌پال: ' . ($result['errors']['message'] ?? 'نامشخص')); + if (!isset($result['data']['code']) || $result['data']['code'] !== 100) { + throw new \Exception('خطا در ارتباط با زرین‌پال: ' . ($result['errors']['message'] ?? $result['data']['message'] ?? 'نامشخص')); } $authority = $result['data']['authority']; @@ -70,9 +67,6 @@ class ZarinpalService ]; } - /** - * بررسی صحت پرداخت (Verification) - */ public function verifyPayment(string $authority, float $expectedAmount): array { $amountToman = (int) round($expectedAmount / 10); @@ -90,6 +84,14 @@ class ZarinpalService 'response' => $result, ]); + if (!isset($result['data']['code'])) { + return [ + 'success' => false, + 'error_code' => -1, + 'error_message' => 'پاسخ نامعتبر از زرین‌پال', + ]; + } + if ($result['data']['code'] === 100) { return [ 'success' => true, @@ -98,7 +100,6 @@ class ZarinpalService 'card_pan' => $result['data']['card_pan'] ?? null, ]; } elseif ($result['data']['code'] === 101) { - // قبلاً verify شده return [ 'success' => true, 'ref_id' => $result['data']['ref_id'] ?? null, @@ -113,13 +114,10 @@ class ZarinpalService ]; } - /** - * ساخت لینک پرداخت - */ private function getPaymentUrl(string $authority): string { - $isProduction = config('ifnex.zarinpal.sandbox', true); - $gatewayUrl = $isProduction + $isSandbox = config('ifnex.zarinpal.sandbox', true); + $gatewayUrl = $isSandbox ? 'https://sandbox.zarinpal.com/pg/StartPay/' : 'https://www.zarinpal.com/pg/StartPay/'; diff --git a/04_Laravel/resources/views/mock-gateway.blade.php b/04_Laravel/resources/views/mock-gateway.blade.php new file mode 100644 index 0000000..37bc3b2 --- /dev/null +++ b/04_Laravel/resources/views/mock-gateway.blade.php @@ -0,0 +1,124 @@ + + + + + + 🔐 درگاه پرداخت شبیه‌سازی شده - IFNEX + + + +
+ + 🧪 محیط تست (Mock) +

درگاه پرداخت IFNEX

+

شبیه‌ساز زرین‌پال — تراکنش واقعی انجام نمی‌شود

+ +
+
مبلغ قابل پرداخت
+
+ {{ number_format($amount) }} + ریال +
+
+ +
+ Authority: {{ $authority }} +
+ +
+ + ✅ پرداخت موفق + + + ❌ انصراف از پرداخت + +
+ +
+ ⚠️ این صفحه فقط برای تست است. در محیط Production، کاربر به درگاه واقعی زرین‌پال هدایت می‌شود. +
+
+ + \ No newline at end of file diff --git a/04_Laravel/resources/views/payment-result.blade.php b/04_Laravel/resources/views/payment-result.blade.php new file mode 100644 index 0000000..a19c286 --- /dev/null +++ b/04_Laravel/resources/views/payment-result.blade.php @@ -0,0 +1,159 @@ + + + + + + {{ $isSuccess ? 'پرداخت موفق' : 'پرداخت ناموفق' }} - IFNEX + + + +
+ @if($isSuccess) +
+

پرداخت موفق

+

پرداخت شما با موفقیت انجام شد و کیف پول شارژ گردید.

+ + @if($amount) +
+
{{ number_format((float)$amount) }}
+
ریال
+
+ @endif + +
+
+ شناسه تراکنش: + #{{ $transactionId }} +
+ @if($refId) +
+ کد پیگیری: + {{ $refId }} +
+ @endif + @if($message) +
+ پیام: + {{ $message }} +
+ @endif +
+ @else +
+

پرداخت ناموفق

+

{{ $message ?? 'پرداخت شما انجام نشد. لطفاً دوباره تلاش کنید.' }}

+ + @if($transactionId) +
+
+ شناسه تراکنش: + #{{ $transactionId }} +
+
+ @endif + @endif + + + بازگشت به پنل مدیریت + + +
+ ⚠️ این یک صفحه تست است. در محیط Production، این صفحه باید با طراحی برند IFNEX تطبیق داده شود. +
+
+ + \ No newline at end of file diff --git a/04_Laravel/routes/api.php b/04_Laravel/routes/api.php index 6d56eca..782ddb2 100644 --- a/04_Laravel/routes/api.php +++ b/04_Laravel/routes/api.php @@ -7,6 +7,9 @@ use App\Http\Controllers\Api\TrackController; use App\Http\Controllers\Api\WalletController; use Illuminate\Support\Facades\Route; use App\Http\Middleware\ApiKeyMiddleware; +use App\Http\Controllers\Api\MockGatewayController; + + // APIهای عمومی با API Key Route::middleware([ApiKeyMiddleware::class])->prefix('v1')->group(function () { @@ -30,6 +33,12 @@ Route::middleware(['auth:sanctum'])->prefix('v1')->group(function () { Route::post('/wallet/{wallet}/unfreeze', [WalletController::class, 'unfreeze']); Route::get('/wallet/{wallet}/activity-log', [WalletController::class, 'activityLog']); }); +// Mock Gateway Routes (عمومی - برای شبیه‌سازی درگاه) +Route::prefix('v1/payment')->group(function () { + Route::get('/mock-gateway', [MockGatewayController::class, 'showGateway']); + Route::get('/mock-gateway/success', [MockGatewayController::class, 'simulateSuccess']); + Route::get('/mock-gateway/failure', [MockGatewayController::class, 'simulateFailure']); +}); // Callback از درگاه (بدون auth) Route::any('/v1/payment/callback', [PaymentController::class, 'callback']) diff --git a/04_Laravel/routes/web.php b/04_Laravel/routes/web.php index c6740cb..fdb12b2 100644 --- a/04_Laravel/routes/web.php +++ b/04_Laravel/routes/web.php @@ -20,3 +20,23 @@ Route::middleware('auth')->group(function () { Route::get('/shipments/{shipment}/pdf/invoice', [ShipmentPdfController::class, 'invoice'])->name('shipments.pdf.invoice'); Route::get('/shipments/{shipment}/pdf/label', [ShipmentPdfController::class, 'label'])->name('shipments.pdf.label'); }); + +// صفحات نتیجه پرداخت (برای تست) +Route::get('/payment-result', function (Illuminate\Http\Request $request) { + $status = $request->input('status'); + $transactionId = $request->input('transaction_id'); + $refId = $request->input('ref_id'); + $amount = $request->input('amount'); + $message = $request->input('message'); + + $isSuccess = $status === 'success'; + + return view('payment-result', [ + 'status' => $status, + 'isSuccess' => $isSuccess, + 'transactionId' => $transactionId, + 'refId' => $refId, + 'amount' => $amount, + 'message' => $message, + ]); +})->name('payment.result'); \ No newline at end of file