From 169196317d6c14439a339a0603889aab126b8b97 Mon Sep 17 00:00:00 2001 From: sadeghpm Date: Sat, 17 Aug 2024 09:28:13 +0330 Subject: [PATCH] Payment gateway added --- .env.example | 15 +-- app/Contracts/Payment.php | 67 ++++++++++ app/Contracts/PaymentStore.php | 40 ++++++ app/Events/InvoiceCompleted.php | 21 +++ app/Events/InvoiceFailed.php | 27 ++++ app/Events/InvoiceSucceed.php | 27 ++++ app/Http/Controllers/CardController.php | 56 ++++++-- .../Payment/GatewayVerifyController.php | 50 ++++++++ app/Models/Invoice.php | 116 +++++++++++++++++ app/Models/Payment.php | 8 ++ app/Payment/Zarinpal.php | 120 ++++++++++++++++++ app/Payment/Zibal.php | 101 +++++++++++++++ app/Providers/AppServiceProvider.php | 8 ++ config/xshop.php | 19 +++ routes/web.php | 3 + 15 files changed, 656 insertions(+), 22 deletions(-) create mode 100755 app/Contracts/Payment.php create mode 100755 app/Contracts/PaymentStore.php create mode 100755 app/Events/InvoiceCompleted.php create mode 100755 app/Events/InvoiceFailed.php create mode 100755 app/Events/InvoiceSucceed.php create mode 100755 app/Http/Controllers/Payment/GatewayVerifyController.php create mode 100755 app/Payment/Zarinpal.php create mode 100755 app/Payment/Zibal.php create mode 100644 config/xshop.php diff --git a/.env.example b/.env.example index 68d7ca3..a13d916 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,7 @@ APP_KEY= APP_DEBUG=true APP_DEPLOYED=false APP_TIMEZONE=UTC -APP_URL=http://localhost:8000 +APP_URL=http://xshop.test APP_LOCALE=en APP_FALLBACK_LOCALE=en @@ -20,12 +20,7 @@ LOG_STACK=single LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug -DB_CONNECTION=mysql -DB_HOST=127.0.0.1 -DB_PORT=3306 -DB_DATABASE=laravel -DB_USERNAME=root -DB_PASSWORD= +DB_CONNECTION=sqlite SESSION_DRIVER=database SESSION_LIFETIME=9999999 @@ -45,7 +40,6 @@ MEMCACHED_HOST=127.0.0.1 PANEL_PREFIX=dashboard PANEL_PAGE_COUNT=30 - REDIS_CLIENT=phpredis REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null @@ -60,7 +54,6 @@ MAIL_ENCRYPTION=null MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_NAME="${APP_NAME}" - MEDIA_WATERMARK_SIZE=15 MEDIA_WATERMARK_OPACITY=50 @@ -72,7 +65,6 @@ AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" - XLANG_ACTIVE=false XLANG_MAIN=en XLANG_API_URL="http://5.255.98.77:3001" @@ -83,3 +75,6 @@ CURRENCY_CODE=USD SIGN_SMS=true SIGN_DRIVER=Kavenegar + +ZARINPAL_MERCHANT=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +PAY_GATEWAY=zarinpal diff --git a/app/Contracts/Payment.php b/app/Contracts/Payment.php new file mode 100755 index 0000000..2f8fee9 --- /dev/null +++ b/app/Contracts/Payment.php @@ -0,0 +1,67 @@ +invoice = $invoice; + } +} diff --git a/app/Events/InvoiceFailed.php b/app/Events/InvoiceFailed.php new file mode 100755 index 0000000..1e8d552 --- /dev/null +++ b/app/Events/InvoiceFailed.php @@ -0,0 +1,27 @@ +invoice = $invoice; + $this->payment = $payment; + } +} diff --git a/app/Events/InvoiceSucceed.php b/app/Events/InvoiceSucceed.php new file mode 100755 index 0000000..cdcca7b --- /dev/null +++ b/app/Events/InvoiceSucceed.php @@ -0,0 +1,27 @@ +invoice = $invoice; + $this->payment = $payment; + } +} diff --git a/app/Http/Controllers/CardController.php b/app/Http/Controllers/CardController.php index eb4ed5e..58aaff1 100644 --- a/app/Http/Controllers/CardController.php +++ b/app/Http/Controllers/CardController.php @@ -2,6 +2,8 @@ namespace App\Http\Controllers; +use App\Contracts\Payment; +use App\Models\Customer; use App\Models\Discount; use App\Models\Invoice; use App\Models\Order; @@ -80,6 +82,7 @@ class CardController extends Controller public function index() { + auth('customer')->login(Customer::first()); $area = 'card'; $title = __("Shopping card"); $subtitle = ''; @@ -97,27 +100,28 @@ class CardController extends Controller ]); $total = 0; // return $request->all(); - $inv = new Invoice(); - $inv->customer_id = auth('customer')->user()->id; - $inv->count = array_sum($request->count); - $inv->address_id = $request->address_id; - $inv->desc = $request->desc; + $invoice = new Invoice(); + $invoice->customer_id = auth('customer')->user()->id; + $invoice->count = array_sum($request->count); + $invoice->address_id = $request->address_id; + $invoice->desc = $request->desc; + if ($request->has('transport_id')) { $request->transport_id = $request->input('transport_id'); $t = Transport::find($request->input('transport_id')); - $inv->transport_price = $t->price; + $invoice->transport_price = $t->price; $total += $t->price; } if ($request->has('discount_id')) { $request->discount_id = $request->input('discount_id'); } - $inv->save(); + $invoice->save(); foreach ($request->product_id as $i => $product) { $order = new Order(); $order->product_id = $product; - $order->invoice_id = $inv->id; + $order->invoice_id = $invoice->id; $order->count = $request->count[$i]; if ($request->quantity_id[$i] != '') { $order->quantity_id = $request->quantity_id[$i]; @@ -133,17 +137,45 @@ class CardController extends Controller $order->save(); } - $inv->total_price = $total; - $inv->save(); + $invoice->total_price = $total; + $invoice->save(); // clear shopping card // self::clear(); - return [$inv, $inv->orders]; + //prepare to redirect to bank gateway + $activeGateway = config('xshop.payment.active_gateway'); + /** @var Payment $gateway */ + $gateway = app($activeGateway . '-gateway'); + logger()->info('pay controller', ["active_gateway" => $activeGateway, "invoice" => $invoice->toArray(),]); + + if ($invoice->isCompleted()) { + return redirect()->back()->with('message', __('Invoice payed.')); + } + + $callbackUrl = route('pay.check', ['invoice_hash' => $invoice->hash, 'gateway' => $gateway->getName()]); + $payment = null; + try { + $response = $gateway->request(($invoice->total_price - $invoice->credit_price), $callbackUrl); + $payment = $invoice->storePaymentRequest($response['order_id'], ($invoice->total_price - $invoice->credit_price), $response['token'] ?? null, null, $gateway->getName()); + session(["payment_id" => $payment->id]); + \Session::save(); + + return $gateway->goToBank(); + } catch (\Throwable $exception) { + $invoice->status = 'FAILED'; + $invoice->save(); + \Log::error("Payment REQUEST exception: " . $exception->getMessage()); + \Log::warning($exception->getTraceAsString()); + $result = false; + $message = __('error in payment. contact admin.'); + return redirect()->back()->withErrors($message); + } + } public static function clear() { - if (auth('customer')->check()){ + if (auth('customer')->check()) { $customer = auth('customer')->user(); $customer->card = null; $customer->save(); diff --git a/app/Http/Controllers/Payment/GatewayVerifyController.php b/app/Http/Controllers/Payment/GatewayVerifyController.php new file mode 100755 index 0000000..91703cb --- /dev/null +++ b/app/Http/Controllers/Payment/GatewayVerifyController.php @@ -0,0 +1,50 @@ +firstOrFail(); + $payment = null; + $message = null; + $result = true; + $paymentId = self::getPayment($invoice); + $response = $gateway->verify(); + $payment = $invoice->storeSuccessPayment($paymentId, $response['reference_id'], $response['card_number']); + session(['card'=>serialize([])]); + } catch (\Throwable $exception) { + $result = false; + $invoice->storeFailPayment($paymentId, $exception->getMessage()); + $message = $exception->getMessage(); + \Log::debug("Payment RESPONSE Fail For Gateway {$gateway->getName()} :" . $exception->getMessage() . " On Line {$exception->getLine()} Of File {$exception->getFile()}", ['request' => request()->all(), 'session' => request()->session()->all(), 'user' => request()->user(), 'payment_id' => $paymentId]); + \Log::warning($exception->getTraceAsString()); + return redirect()->route('client.card')->withErrors(__("error in payment.").$message); + } + + return redirect()->route('client.profile')->with('message' , __("payment success")); + + } + + /** + * @param Invoice $invoice + * @return integer + */ + public static function getPayment($invoice) + { + $paymentId = session('payment_id'); + if (empty($paymentId)) { + $paymentId = $invoice->payments->last()->id; + } + return $paymentId; + } +} diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php index 68e14a1..78411fc 100644 --- a/app/Models/Invoice.php +++ b/app/Models/Invoice.php @@ -2,6 +2,8 @@ namespace App\Models; +use App\Events\InvoiceFailed; +use App\Events\InvoiceSucceed; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -9,6 +11,15 @@ class Invoice extends Model { use HasFactory; + const PENDING = 'PENDING'; + const PROCESSING = 'PROCESSING'; + const COMPLETED = 'COMPLETED'; + const CANCELED = 'CANCELED'; + const FAILED = 'FAILED'; + + protected $casts = [ + 'meta' => 'array', + ]; public static $invoiceStatus = ['PENDING', 'CANCELED', 'FAILED', 'PAID', 'PROCESSING', 'COMPLETED']; @@ -16,6 +27,45 @@ class Invoice extends Model { return 'hash'; } + public function customer() + { + return $this->belongsTo(Customer::class); + } + + public function payments() + { + return $this->hasMany(Payment::class); + } + + public function successPayments() + { + return $this->hasMany(Payment::class)->where('status', 'COMPLETED'); + } + + public function payByBankUrl($gateway) + { + return route('redirect.bank', ['invoice' => $this->id, 'gateway' => $gateway]); + } + + /** + * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + */ + public function products() + { + return $this->belongsToMany(Product::class, 'invoice_product') + ->withPivot( + 'count', + 'price_total', + 'data', + 'quantity_id' + ); + } + + + public function isCompleted() + { + return $this->status == 'COMPLETED' or $this->status == 'PROCESSING'; + } public function orders() @@ -30,4 +80,70 @@ class Invoice extends Model $model->hash = generateUniqueID((strlen(Invoice::count()) + 2)); }); } + + public function storePaymentRequest($orderId,$amount, $token = null, $type = 'ONLINE', $bank = null): \App\Models\Payment + { + $payment = new Payment(); + $payment->order_id = $orderId; + $payment->type = $type?$type:'ONLINE'; + $payment->amount=$amount; + $payment->meta = [ + 'fingerprint' => \Request::fingerprint(), + 'bank' => $bank, + 'token' => $token, + 'ip' => \Request::ip(), + 'auth_user' => \Auth::id(), + 'user_agent' => \Request::userAgent(), + ]; + /** @var \App\Models\Invoice $this */ + $this->payments()->save($payment); + +// $payment->save(); + + return $payment; + } + + public function storeSuccessPayment($paymentId, $referenceId, $cardNumber = null): \App\Models\Payment + { + /** @var Payment $payment */ + $payment = Payment::findOrFail($paymentId); + $payment->reference_id = $referenceId; + $payment->meta = array_merge($payment->meta, ['card_number' => $cardNumber]); + $payment->status = "SUCCESS"; + $payment->save(); + /** @var \App\Models\Invoice $this */ + $this->status = "COMPLETED"; + $this->save(); + try { + event(new InvoiceSucceed($this, $payment)); + }catch (\Throwable $exception){ + \Log::debug('Error In Event OrderSucceed. But Process Continued!',compact('payment')); + \Log::warning($exception->getMessage(),[$exception->getTraceAsString()]); + } + + return $payment; + } + + public function storeFailPayment($paymentId, $message = null): \App\Models\Payment + { + try { + /** @var Payment $payment */ + $payment = Payment::findOrFail($paymentId); + if ($payment->status === Payment::SUCCESS) { + return $payment; + } + $payment->status = Payment::FAIL; + $payment->comment = $message; + $payment->save(); + } catch (\Throwable $exception) { + $payment = new Payment(); + } + $this->status = "FAILED"; + /** @var \App\Models\Invoice $this */ + $this->save(); + event(new InvoiceFailed($this, $payment)); + + return $payment; + } + } diff --git a/app/Models/Payment.php b/app/Models/Payment.php index 994b377..ee32ba2 100644 --- a/app/Models/Payment.php +++ b/app/Models/Payment.php @@ -8,6 +8,14 @@ use Illuminate\Database\Eloquent\Model; class Payment extends Model { use HasFactory; + const PENDING = 'PENDING'; + const SUCCESS = 'SUCCESS'; + const FAIL = 'FAIL'; + const CANCEL = 'CANCEL'; + + protected $casts = [ + 'meta' => 'array', + ]; public static $types = ['ONLINE', 'CHEQUE', 'CASH', 'CARD', 'CASH_ON_DELIVERY']; public static $status = ['PENDING','SUCCESS', 'FAIL','CANCEL']; diff --git a/app/Payment/Zarinpal.php b/app/Payment/Zarinpal.php new file mode 100755 index 0000000..918fb96 --- /dev/null +++ b/app/Payment/Zarinpal.php @@ -0,0 +1,120 @@ +gateway = $gateway; + } + + /** + * Get Payment name + * + * @return string + */ + public static function getName(): string + { + return 'zarinpal'; + } + + public static function getType(): string + { + return 'ONLINE'; + } + + /** + * Request online payment + * + * @param int $amount transaction amount + * @param string $callbackUrl return user after transaction to this url + * @param array $additionalData additional data to send back + * + * @return array request data like token,order_id + * @throws \Exception + */ + public function request(int $amount, string $callbackUrl, array $additionalData = []): array + { + $result = $this->gateway->amount($amount )->request()->callbackUrl($callbackUrl)->description(config('app.name'))->send(); + + throw_unless($result->success(), \Exception::class, $result->error()->message()); + + \Session::put('zarinpal_amount', $amount); + \Session::put('zarinpal_token', $result->authority()); + \Session::save(); + + $this->token = $result->authority(); + $this->result = $result; + return [ + 'order_id' => $result->authority(), + 'token' => null + ]; + } + + /** + * Redirect customer to bank payment page + */ + public function goToBank() + { + return redirect()->away($this->result->url()); + } + + /** + * Verify payment + * @return array successful payment result.The array contain 2 keys: card_number, reference_id. The reference_id is reference number in banking network + * @throws \Throwable if payment fail + */ + public function verify(): array + { + $result = $this->gateway->amount(session('zarinpal_amount')) + ->verification() + ->authority(session('zarinpal_token')) + ->send(); + throw_if( + !$result->success(), + \Exception::class, + $result->error()->message() + ); + return [ + 'reference_id' => $result->referenceId(), + 'card_number' => $result->cardPan(), + ]; + } + + + public static function registerService() + { + app()->singleton( + sprintf('%s-gateway', self::getName()), + function () { + $gateway = zarinpal() + ->merchantId(config('xshop.payment.config.zarinpal.merchant')); + return new Zarinpal($gateway); + } + ); + } + + public static function isActive(): bool + { + return !empty(config('xshop.payment.config.zarinpal.merchant')); + } + + public static function getLogo() + { + return asset('payment/image/shaparak.png'); + } + +} diff --git a/app/Payment/Zibal.php b/app/Payment/Zibal.php new file mode 100755 index 0000000..25372b0 --- /dev/null +++ b/app/Payment/Zibal.php @@ -0,0 +1,101 @@ +gateway = $gateway; + } + + /** + * Get Payment name + * + * @return string + */ + public static function getName(): string + { + return 'zibal'; + } + + public static function getType(): string + { + return 'ONLINE'; + } + + /** + * Request online payment + * + * @param int $amount transaction amount + * @param string $callbackUrl a url that callback user after transaction + * @param array $additionalData additional data to send back + * @return array request data like token,order_id + * @throws \Exception + */ + public function request(int $amount, string $callbackUrl, array $additionalData = []): array + { + $result = $this->gateway->request($callbackUrl, $amount); + \Session::put('zibal_amount', $amount); + \Session::put('zibal_invoice_id', $result['invoice_id']); + \Session::put('zibal_token', $result['token']); + return [ + 'order_id' => $result['invoice_id'], + 'token' => $result['token'] + ]; + } + + /** + * Redirect customer to bank payment page + */ + public function goToBank() + { + return redirect()->away($this->gateway->redirectUrl()); + } + + /** + * Verify payment + * @return array successful payment result.The array contain 3 key: card_number, invoice_id & reference_id. The reference_id is reference number in banking network + * @throws \Exception if payment fail + */ + public function verify(): array + { + $result = $this->gateway->verify(session('zibal_amount'), session('zibal_token')); + return [ + 'reference_id' => $result['transaction_id'], + 'card_number' => $result['card_number'], + ]; + } + + + public static function registerService() + { + app()->singleton( + sprintf('%s-gateway',self::getName()), + function () { + $gateway = new \Dpsoft\Zibal\Zibal(config('xshop.payment.config.zibal.merchant')); + + return new Zibal($gateway); + } + ); + } + + public static function isActive():bool + { + return !empty(config('xshop.payment.config.zibal.merchant')); + } + + public static function getLogo() + { + return asset('payment/image/shaparak.png'); + } + +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 96bbd59..3362219 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -24,6 +24,14 @@ class AppServiceProvider extends ServiceProvider $this->commands([ TranslatorCommand::class, ]); + foreach (config('xshop.payment.gateways') as $gateway){ + /** @var \App\Contracts\Payment $gateway */ + $gateway::registerService(); + } + + \Route::bind('gateway', function ($gatewayName) { + return app("$gatewayName-gateway"); + }); } /** diff --git a/config/xshop.php b/config/xshop.php new file mode 100644 index 0000000..c0acd19 --- /dev/null +++ b/config/xshop.php @@ -0,0 +1,19 @@ + [ + 'active_gateway' => env('PAY_GATEWAY', \App\Payment\Zarinpal::getName()), + 'gateways' => [ + \App\Payment\Zibal::class, + \App\Payment\Zarinpal::class, + ], + 'config' => [ + 'zibal' => [ + 'merchant' => env('ZIBAL_MERCHANT', 'zibal'), + ], + 'zarinpal' => [ + 'merchant' => env('ZARINPAL_MERCHANT'), + 'test' => env('ZARINPAL_TEST') + ], + ], + ] +]; diff --git a/routes/web.php b/routes/web.php index 8b1e9d2..9775cb8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -436,6 +436,9 @@ Route::get('whoami', function () { return \Auth::guard('customer')->user(); })->name('whoami'); +//Route::get('/payment/redirect/bank/{invoice}/{gateway}', \App\Http\Controllers\Payment\GatewayRedirectController::class)->name('redirect.bank'); +Route::any('/payment/check/{invoice_hash}/{gateway}', \App\Http\Controllers\Payment\GatewayVerifyController::class)->name('pay.check'); + Route::any('{lang}/{any}', [ClientController::class, 'lang']) ->where('any', '.*') ->where('lang','[A-Za-z]{2}')