From 50ae20b14acf549b8032be4c4139c3870f419b0b Mon Sep 17 00:00:00 2001 From: Kazem Alghasi Date: Wed, 5 Aug 2026 23:49:33 +0330 Subject: [PATCH] feat(api): implement payment gateway integration Add Zarinpal configuration options and implement the core payment infrastructure, including the Payment model, migration, service layer, and API controller. --- .../Controllers/Api/PaymentController.php | 103 +++++++++ 04_Laravel/app/Models/Payment.php | 38 ++++ .../app/Services/PaymentGatewayService.php | 203 ++++++++++++++++++ 04_Laravel/config/ifnex.php | 3 + ...026_08_05_140000_create_payments_table.php | 32 +++ 5 files changed, 379 insertions(+) create mode 100644 04_Laravel/app/Http/Controllers/Api/PaymentController.php create mode 100644 04_Laravel/app/Models/Payment.php create mode 100644 04_Laravel/app/Services/PaymentGatewayService.php create mode 100644 04_Laravel/database/migrations/2026_08_05_140000_create_payments_table.php diff --git a/04_Laravel/app/Http/Controllers/Api/PaymentController.php b/04_Laravel/app/Http/Controllers/Api/PaymentController.php new file mode 100644 index 0000000..3542ee0 --- /dev/null +++ b/04_Laravel/app/Http/Controllers/Api/PaymentController.php @@ -0,0 +1,103 @@ +user(); + + if (!$user) { + return response()->json(['message' => 'Unauthorized'], 401); + } + + try { + $validated = $request->validate([ + 'amount' => ['required', 'numeric', 'min:1000'], + 'description' => ['nullable', 'string', 'max:500'], + ]); + } catch (ValidationException $e) { + return response()->json([ + 'message' => 'Validation failed', + 'errors' => $e->errors(), + ], 422); + } + + $wallet = $user->wallet()->first(); + + if (!$wallet) { + $wallet = Wallet::create([ + 'user_id' => $user->id, + 'balance' => 0, + ]); + } + + $result = $this->gateway->requestPayment( + $user, + $validated['amount'], + $validated['description'] ?? 'شارچ کیف پول' + ); + + if (!$result['success']) { + return response()->json($result, 400); + } + + return response()->json([ + 'message' => 'درخواست پرداخت ایجاد شد', + 'authority' => $result['authority'], + 'payment_url' => $result['payment_url'] ?? null, + 'mock' => $result['mock'] ?? false, + ]); + } + + public function callback(Request $request): JsonResponse + { + $authority = $request->query('Authority'); + $status = $request->query('Status'); + + if (!$authority) { + return response()->json(['message' => 'Authority not provided'], 400); + } + + if ($status !== 'OK') { + return response()->json([ + 'message' => 'پرداخت لغو شد.', + 'status' => $status, + ]); + } + + $result = $this->gateway->verifyPayment($authority); + + if (!$result['success']) { + return response()->json($result, 400); + } + + return response()->json([ + 'message' => $result['message'], + 'ref_id' => $result['ref_id'], + 'amount' => $result['amount'], + ]); + } + + public function status(Request $request, string $authority): JsonResponse + { + $result = $this->gateway->checkPayment($authority); + + if (!$result['success']) { + return response()->json($result, 404); + } + + return response()->json($result); + } +} \ No newline at end of file diff --git a/04_Laravel/app/Models/Payment.php b/04_Laravel/app/Models/Payment.php new file mode 100644 index 0000000..753ad34 --- /dev/null +++ b/04_Laravel/app/Models/Payment.php @@ -0,0 +1,38 @@ + 'integer', + 'paid_at' => 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function wallet(): BelongsTo + { + return $this->belongsTo(Wallet::class); + } +} \ No newline at end of file diff --git a/04_Laravel/app/Services/PaymentGatewayService.php b/04_Laravel/app/Services/PaymentGatewayService.php new file mode 100644 index 0000000..94e4c68 --- /dev/null +++ b/04_Laravel/app/Services/PaymentGatewayService.php @@ -0,0 +1,203 @@ +mockPayment($user, $amount, $description); + } + + $callbackUrl = config('ifnex.zarinpal_callback_url', url('/payment/callback')); + + $response = Http::post('https://api.zarinpal.com/pg/v4/payment/request', [ + 'merchant_id' => $merchantId, + 'amount' => (int) ($amount * 10), + 'callback_url' => $callbackUrl, + 'description' => $description ?? 'شارچ کیف پول IFNEX', + 'metadata' => [ + 'user_id' => $user->id, + 'email' => $user->email, + ], + ]); + + $data = $response->json(); + + if (($data['data']['code'] ?? 0) !== 100) { + Log::error('ZarinPal payment request failed', ['response' => $data]); + + return [ + 'success' => false, + 'message' => 'خطا در درخواست پرداخت.', + 'code' => $data['data']['code'] ?? 0, + ]; + } + + $authority = $data['data']['authority']; + + $this->payment->create([ + 'user_id' => $user->id, + 'gateway' => 'zarinpal', + 'merchant_id' => $merchantId, + 'authority' => $authority, + 'amount' => (int) ($amount * 10), + 'currency' => 'IRR', + 'status' => 'pending', + 'description' => $description, + ]); + + return [ + 'success' => true, + 'authority' => $authority, + 'payment_url' => "https://www.zarinpal.com/pg/StartPay/{$authority}", + ]; + } + + public function verifyPayment(string $authority, string $refId = null): array + { + $merchantId = config('ifnex.zarinpal_merchant_id'); + + $payment = $this->payment->where('authority', $authority)->first(); + + if (!$payment) { + return [ + 'success' => false, + 'message' => 'تراکنش پیدا نشد.', + ]; + } + + if ($payment->status !== 'pending') { + return [ + 'success' => false, + 'message' => 'این تراکنش قبلاً بررسی شده است.', + ]; + } + + if (!$merchantId || $merchantId === 'change-me') { + return $this->mockVerify($payment); + } + + $response = Http::post("https://api.zarinpal.com/pg/v4/payment/verify/{$merchantId}", [ + 'merchant_id' => $merchantId, + 'authority' => $authority, + 'amount' => $payment->amount, + ]); + + $data = $response->json(); + + if (($data['data']['code'] ?? 0) !== 100) { + $payment->update(['status' => 'failed']); + + return [ + 'success' => false, + 'message' => 'تأیید پرداخت ناموفق بود.', + 'code' => $data['data']['code'] ?? 0, + ]; + } + + $refId = $data['data']['ref_id'] ?? null; + + $payment->update([ + 'status' => 'completed', + 'ref_id' => $refId, + 'paid_at' => now(), + ]); + + if ($payment->wallet_id) { + $wallet = Wallet::find($payment->wallet_id); + if ($wallet) { + $wallet->increment('balance', $payment->amount); + } + } + + return [ + 'success' => true, + 'message' => 'پرداخت با موفقیت تأیید شد.', + 'ref_id' => $refId, + 'amount' => $payment->amount, + ]; + } + + public function checkPayment(string $authority): array + { + $payment = $this->payment->where('authority', $authority)->first(); + + if (!$payment) { + return [ + 'success' => false, + 'message' => 'تراکنش پیدا نشد.', + ]; + } + + return [ + 'success' => true, + 'status' => $payment->status, + 'amount' => $payment->amount, + 'ref_id' => $payment->ref_id, + 'paid_at' => $payment->paid_at?->toIso8601String(), + ]; + } + + private function mockPayment(User $user, float $amount, string $description = null): array + { + $this->payment->create([ + 'user_id' => $user->id, + 'gateway' => 'zarinpal', + 'amount' => (int) ($amount * 10), + 'currency' => 'IRR', + 'status' => 'completed', + 'description' => $description, + 'paid_at' => now(), + ]); + + $user->wallet()->updateOrCreate( + [], + ['balance' => 0] + ); + + $wallet = $user->wallet()->first(); + $wallet->increment('balance', (int) ($amount * 10)); + + return [ + 'success' => true, + 'authority' => 'MOCK-' . strtoupper(uniqid()), + 'payment_url' => null, + 'mock' => true, + ]; + } + + private function mockVerify(Payment $payment): array + { + $payment->update([ + 'status' => 'completed', + 'ref_id' => 'MOCK-' . strtoupper(uniqid()), + 'paid_at' => now(), + ]); + + if ($payment->wallet_id) { + $wallet = Wallet::find($payment->wallet_id); + if ($wallet) { + $wallet->increment('balance', $payment->amount); + } + } + + return [ + 'success' => true, + 'message' => 'پرداخت موقت تأیید شد (sandbox).', + 'ref_id' => $payment->ref_id, + 'amount' => $payment->amount, + ]; + } +} \ No newline at end of file diff --git a/04_Laravel/config/ifnex.php b/04_Laravel/config/ifnex.php index c369b76..01cb651 100644 --- a/04_Laravel/config/ifnex.php +++ b/04_Laravel/config/ifnex.php @@ -24,4 +24,7 @@ return [ 'currency_api_key' => env('CURRENCY_API_KEY'), + 'zarinpal_merchant_id' => env('ZARINPAL_MERCHANT_ID'), + 'zarinpal_callback_url' => env('ZARINPAL_CALLBACK_URL', 'http://localhost/payment/callback'), + ]; diff --git a/04_Laravel/database/migrations/2026_08_05_140000_create_payments_table.php b/04_Laravel/database/migrations/2026_08_05_140000_create_payments_table.php new file mode 100644 index 0000000..488a517 --- /dev/null +++ b/04_Laravel/database/migrations/2026_08_05_140000_create_payments_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('wallet_id')->nullable()->constrained()->nullOnDelete(); + $table->string('gateway')->default('zarinpal'); + $table->string('merchant_id')->nullable(); + $table->string('authority')->nullable(); + $table->string('ref_id')->nullable(); + $table->unsignedBigInteger('amount'); + $table->string('currency')->default('IRR'); + $table->string('status')->default('pending'); + $table->text('description')->nullable(); + $table->timestamp('paid_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('payments'); + } +}; \ No newline at end of file