From b896173fa28ac1fa2509761f620ec9ddaf15de9c Mon Sep 17 00:00:00 2001 From: khamdullaevuz Date: Fri, 21 Jul 2023 11:10:58 +0500 Subject: [PATCH] payme merchant api package created --- .gitignore | 3 + LICENSE | 21 ++ README.md | 61 ++++ composer.json | 30 ++ ...092809_create_payme_transactions_table.php | 37 +++ src/Enums/PaymeMethods.php | 14 + src/Enums/PaymeState.php | 11 + src/Exceptions/PaymeException.php | 171 +++++++++++ src/Exceptions/PaymeExceptionHandler.php | 37 +++ src/Facades/Payme.php | 18 ++ src/Handlers/PaymeRequestHandler.php | 37 +++ src/Http/Middleware/PaymeCheck.php | 37 +++ src/Models/PaymeTransaction.php | 29 ++ src/Payme.php | 21 ++ src/PaymeServiceProvider.php | 32 ++ src/Services/PaymeService.php | 278 ++++++++++++++++++ src/Traits/JsonRPC.php | 24 ++ src/Traits/PaymeHelper.php | 100 +++++++ src/config/payme.php | 13 + 19 files changed, 974 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 database/migrations/2023_07_04_092809_create_payme_transactions_table.php create mode 100644 src/Enums/PaymeMethods.php create mode 100644 src/Enums/PaymeState.php create mode 100644 src/Exceptions/PaymeException.php create mode 100644 src/Exceptions/PaymeExceptionHandler.php create mode 100644 src/Facades/Payme.php create mode 100644 src/Handlers/PaymeRequestHandler.php create mode 100644 src/Http/Middleware/PaymeCheck.php create mode 100644 src/Models/PaymeTransaction.php create mode 100644 src/Payme.php create mode 100644 src/PaymeServiceProvider.php create mode 100644 src/Services/PaymeService.php create mode 100644 src/Traits/JsonRPC.php create mode 100644 src/Traits/PaymeHelper.php create mode 100644 src/config/payme.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8925195 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.idea +vendor +composer.lock \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e149ff3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Elbek Khamdullaev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3266356 --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# Usage + +## Installation + +```bash +composer require khamdullaevuz/laravel-payme +``` + +## Configuration + +```bash +php artisan vendor:publish --tag=payme-config +``` + +## Add your configs to `config/payme.php` +```php + +return [ + 'min_amount' => env('PAYME_MIN_AMOUNT', 1_000_00), + 'max_amount' => env('PAYME_MAX_AMOUNT', 100_000_000_00), + 'identity' => env('PAYME_IDENTITY', 'id'), + 'login' => env('PAYME_LOGIN', 'TestUser'), + 'key' => env('PAYME_KEY', 'TestKey'), + 'merchant_id' => env('PAYME_MERCHANT_ID', '123456789'), + 'allowed_ips' => [ + '185.178.51.131', '185.178.51.132', '195.158.31.134', '195.158.31.10', '195.158.28.124', '195.158.5.82', '127.0.0.1' + ] +]; +``` + +## Add service provider to `config/app.php` + +```php +'providers' => [ + // Other Service Providers + Khamdullaevuz\Payme\PaymeServiceProvider::class, +], +``` + +## Add facade to globally aliases in `config/app.php` + +```php +'aliases' => [ + // Other Aliases + 'Payme' => Khamdullaevuz\Payme\Facades\Payme::class, +], +``` + +## Usage in route + +```php +use Khamdullaevuz\Payme\Facades\Payme; +use Khamdullaevuz\Payme\Http\Middleware\PaymeCheck; +use Illuminate\Http\Request; + +// Other Routes + +Route::any('/payme', function (Request $request) { + return Payme::handle($request); +})->middleware(PaymeCheck::class); +``` \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..46e8f01 --- /dev/null +++ b/composer.json @@ -0,0 +1,30 @@ +{ + "name": "khamdullaevuz/laravel-payme", + "description": "Laravel Payme", + "type": "library", + "authors": [ + { + "name": "Elbek Khamdullaev", + "email": "elbek.khamdullaev@gmail.com" + } + ], + "require": { + "php": "^8.1", + "illuminate/support": "^10.0|^9.0" + }, + "autoload": { + "psr-4": { + "Khamdullaevuz\\Payme\\": "src/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Khamdullaevuz\\Payme\\PaymeServiceProvider" + ], + "aliases": { + "Payme": "Khamdullaevuz\\Payme\\Facades\\Payme" + } + } + } +} \ No newline at end of file diff --git a/database/migrations/2023_07_04_092809_create_payme_transactions_table.php b/database/migrations/2023_07_04_092809_create_payme_transactions_table.php new file mode 100644 index 0000000..0bf785a --- /dev/null +++ b/database/migrations/2023_07_04_092809_create_payme_transactions_table.php @@ -0,0 +1,37 @@ +id(); + $table->string('transaction')->nullable(); + $table->string('code')->nullable(); + $table->string('state')->nullable(); + $table->string('owner_id')->nullable(); + $table->bigInteger('amount')->nullable(); + $table->string('reason')->nullable(); + $table->string('payme_time')->nullable(); + $table->string('cancel_time')->nullable(); + $table->string('create_time')->nullable(); + $table->string('perform_time')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('payme_transactions'); + } +}; \ No newline at end of file diff --git a/src/Enums/PaymeMethods.php b/src/Enums/PaymeMethods.php new file mode 100644 index 0000000..68bb372 --- /dev/null +++ b/src/Enums/PaymeMethods.php @@ -0,0 +1,14 @@ +error = [ + 'code' => $error, + 'message' => $customMessage ?? $this->getErrorMessage($error) + ]; + + parent::__construct(); + } + + public function getErrorMessage($code): array + { + $messages = [ + + self::INVALID_HTTP_METHOD => [ + "uz" => "Xato so'rov", + "ru" => "Ошибка запроса", + "en" => "Error request" + ], + + self::SYSTEM_ERROR => [ + "uz" => "Ichki sestema hatoligi", + "ru" => "Внутренняя ошибка сервера", + "en" => "Internal server error" + ], + + self::WRONG_AMOUNT => [ + "uz" => "Notug'ri summa.", + "ru" => "Неверная сумма.", + "en" => "Wrong amount.", + ], + + self::USER_NOT_FOUND => [ + "uz" => "Foydalanuvchi topilmadi", + "ru" => "Пользователь не найден", + "en" => "User not found", + ], + + self::JSON_RPC_ERROR => [ + "uz" => "Notog`ri JSON-RPC obyekt yuborilgan.", + "ru" => "Передан неправильный JSON-RPC объект.", + "en" => "Handed the wrong JSON-RPC object." + ], + + self::TRANS_NOT_FOUND => [ + "uz" => "Transaction not found", + "ru" => "Трансакция не найдена", + "en" => "Transaksiya topilmadi" + ], + + self::METHOD_NOT_FOUND => [ + "uz" => "Metod topilmadi", + "ru" => "Запрашиваемый метод не найден.", + "en" => "Method not found" + ], + + self::JSON_PARSING_ERROR => [ + "uz" => "Json pars qilganda hatolik yuz berdi", + "ru" => "Ошибка при парсинге JSON", + "en" => "Error while parsing json" + ], + + self::CANT_PERFORM_TRANS => [ + "uz" => "Bu operatsiyani bajarish mumkin emas", + "ru" => "Невозможно выполнить данную операцию.", + "en" => "Can't perform transaction", + ], + + self::CANT_CANCEL_TRANSACTION => [ + "uz" => "Transaksiyani qayyarib bolmaydi", + "ru" => "Невозможно отменить транзакцию", + "en" => "You can not cancel the transaction" + ], + + self::PENDING_PAYMENT => [ + "uz" => "To'lov kutilmoqda", + "ru" => "В ожидании оплаты", + "en" => "Pending payment" + ], + + self::AUTH_ERROR => [ + "uz" => "Avtorizatsiyadan otishda xatolik", + "ru" => "Ошибка аутентификации", + "en" => "Auth error" + ] + ]; + + return $messages[$code] ?? []; + } +} \ No newline at end of file diff --git a/src/Exceptions/PaymeExceptionHandler.php b/src/Exceptions/PaymeExceptionHandler.php new file mode 100644 index 0000000..5b2627c --- /dev/null +++ b/src/Exceptions/PaymeExceptionHandler.php @@ -0,0 +1,37 @@ + + */ + protected $dontFlash = [ + 'current_password', + 'password', + 'password_confirmation', + ]; + + /** + * Register the exception handling callbacks for the application. + */ + public function register(): void + { + $this->reportable(function (Throwable $e) { + // + }); + + $this->renderable(function (PaymeException $e){ + return $this->error($e->error); + }); + } +} diff --git a/src/Facades/Payme.php b/src/Facades/Payme.php new file mode 100644 index 0000000..9eeeaf5 --- /dev/null +++ b/src/Facades/Payme.php @@ -0,0 +1,18 @@ +method() !== 'POST') { + throw new PaymeException(PaymeException::INVALID_HTTP_METHOD); + } + + $data = $request->all(); + + if(!isset($data['method']) || !isset($data['params'])) { + throw new PaymeException(PaymeException::JSON_PARSING_ERROR); + } + + $this->method = $data['method']; + $this->params = $data['params']; + + if(Rule::enum(PaymeMethods::class)->passes('', $this->method) === false) { + throw new PaymeException(PaymeException::METHOD_NOT_FOUND); + } + } +} \ No newline at end of file diff --git a/src/Http/Middleware/PaymeCheck.php b/src/Http/Middleware/PaymeCheck.php new file mode 100644 index 0000000..18e8b34 --- /dev/null +++ b/src/Http/Middleware/PaymeCheck.php @@ -0,0 +1,37 @@ +header('Authorization'); + if(!$authorization || + !preg_match('/^\s*Basic\s+(\S+)\s*$/i', $authorization, $matches) || + base64_decode($matches[1]) != config('payme.login') . ":" . config('payme.key')) + { + throw new PaymeException(PaymeException::AUTH_ERROR); + } + + $ip = $request->ip(); + + if(!in_array($ip, config('payme.allowed_ips'))) + { + throw new PaymeException(PaymeException::AUTH_ERROR); + } + + return $next($request); + } +} diff --git a/src/Models/PaymeTransaction.php b/src/Models/PaymeTransaction.php new file mode 100644 index 0000000..079fd25 --- /dev/null +++ b/src/Models/PaymeTransaction.php @@ -0,0 +1,29 @@ + PaymeState::class, + 'create_time' => 'integer', + 'perform_time' => 'integer', + 'cancel_time' => 'integer', + 'reason' => 'integer', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'owner_id'); + } +} \ No newline at end of file diff --git a/src/Payme.php b/src/Payme.php new file mode 100644 index 0000000..f6586d7 --- /dev/null +++ b/src/Payme.php @@ -0,0 +1,21 @@ +params))->{$handler->method}(); + } +} \ No newline at end of file diff --git a/src/PaymeServiceProvider.php b/src/PaymeServiceProvider.php new file mode 100644 index 0000000..97858c8 --- /dev/null +++ b/src/PaymeServiceProvider.php @@ -0,0 +1,32 @@ +publishes([ + __DIR__ . '/config/payme.php' => config_path('payme.php'), + ], 'payme-config'); + + $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); + } + + public function register(): void + { + $this->app->singleton( + ExceptionHandler::class, + PaymeExceptionHandler::class + ); + + $this->app->bind('payme', function () { + return new Payme(); + }); + } +} diff --git a/src/Services/PaymeService.php b/src/Services/PaymeService.php new file mode 100644 index 0000000..c673197 --- /dev/null +++ b/src/Services/PaymeService.php @@ -0,0 +1,278 @@ +minAmount = config('payme.min_amount', $this->minAmount); + $this->maxAmount = config('payme.max_amount', $this->maxAmount); + $this->identity = config('payme.identity', $this->identity); + } + + /** + * @throws PaymeException + */ + public function CheckPerformTransaction(): JsonResponse + { + if(!$this->hasParam(['amount', 'account'])) + { + throw new PaymeException(PaymeException::JSON_RPC_ERROR); + } + $amount = $this->params['amount']; + + if(!$this->isValidAmount($amount)) + { + throw new PaymeException(PaymeException::WRONG_AMOUNT); + } + + $account = $this->params['account']; + if(!array_key_exists($this->identity, $account)) + { + throw new PaymeException(PaymeException::USER_NOT_FOUND); + } + + $account = $account[$this->identity]; + + $user = User::where($this->identity, $account)->first(); + + if(!$user) + { + throw new PaymeException(PaymeException::USER_NOT_FOUND); + } + + return $this->successCheckPerformTransaction(); + } + + /** + * @throws PaymeException + */ + public function CreateTransaction(){ + if(!$this->hasParam(['id', 'time', 'amount', 'account'])) + { + throw new PaymeException(PaymeException::JSON_RPC_ERROR); + } + $id = $this->params['id']; + $time = $this->params['time']; + $amount = $this->params['amount']; + $account = $this->params['account']; + + if(!array_key_exists($this->identity, $account)) + { + throw new PaymeException(PaymeException::USER_NOT_FOUND); + } + + $account = $account[$this->identity]; + + $user = User::where($this->identity, $account)->first(); + + if(!$user) + { + throw new PaymeException(PaymeException::USER_NOT_FOUND); + } + + if(!$this->isValidAmount($amount)) + { + throw new PaymeException(PaymeException::WRONG_AMOUNT); + } + + $transaction = PaymeTransaction::where('transaction', $id)->first(); + + if($transaction) + { + if ($transaction->state != PaymeState::Pending) { + throw new PaymeException(PaymeException::CANT_PERFORM_TRANS); + } + + if(!$this->checkTimeout($transaction->create_time)) + { + $transaction->update([ + 'state' => PaymeState::Cancelled, + 'reason' => 4 + ]); + + throw new PaymeException(error: PaymeException::CANT_PERFORM_TRANS, customMessage: [ + "uz" => "Vaqt tugashi o'tdi", + "ru" => "Тайм-аут прошел", + "en" => "Timeout passed" + ]); + } + + return $this->successCreateTransaction( + $transaction->create_time, + $transaction->id, + $transaction->state + ); + } + + $transaction = PaymeTransaction::create([ + 'transaction' => $id, + 'payme_time' => $time, + 'amount' => $amount, + 'state' => PaymeState::Pending, + 'create_time' => $this->microtime(), + 'owner_id' => $account, + ]); + + return $this->successCreateTransaction( + $transaction->create_time, + $transaction->id, + $transaction->state + ); + } + + /** + * @throws PaymeException + */ + public function PerformTransaction(){ + if(!$this->hasParam('id')) + { + throw new PaymeException(PaymeException::JSON_RPC_ERROR); + } + $id = $this->params['id']; + + $transaction = PaymeTransaction::where('transaction', $id)->first(); + if(!$transaction) + { + throw new PaymeException(PaymeException::TRANS_NOT_FOUND); + } + + if($transaction->state !== PaymeState::Pending){ + if($transaction->state == PaymeState::Done) + { + return $this->successPerformTransaction($transaction->state, $transaction->perform_time, $transaction->id); + }else{ + throw new PaymeException(PaymeException::CANT_PERFORM_TRANS); + } + } + + if(!$this->checkTimeout($transaction->create_time)) + { + $transaction->update([ + 'state' => PaymeState::Cancelled, + 'reason' => 4 + ]); + throw new PaymeException(error: PaymeException::CANT_PERFORM_TRANS, customMessage: [ + "uz" => "Vaqt tugashi o'tdi", + "ru" => "Тайм-аут прошел", + "en" => "Timeout passed" + ]); + } + + $transaction->state = PaymeState::Done; + $transaction->perform_time = $this->microtime(); + $transaction->save(); + + $this->fillUpBalance($transaction->user, $transaction->amount); + + return $this->successPerformTransaction($transaction->state, $transaction->perform_time, $transaction->id); + } + + /** + * @throws PaymeException + */ + public function CancelTransaction(){ + if(!$this->hasParam(['id', 'reason'])) + { + throw new PaymeException(PaymeException::JSON_RPC_ERROR); + } + if(!array_key_exists('reason', $this->params)) + { + throw new PaymeException(PaymeException::JSON_RPC_ERROR); + } + + $id = $this->params['id']; + $reason = $this->params['reason']; + + $transaction = PaymeTransaction::where('transaction', $id)->first(); + if(!$transaction){ + throw new PaymeException(PaymeException::TRANS_NOT_FOUND); + } + + if ($transaction->state == PaymeState::Pending) { + $cancelTime = $this->microtime(); + $transaction->update([ + "state" => PaymeState::Cancelled, + "cancel_time" => $cancelTime, + "reason" => $reason + ]); + + return $this->successCancelTransaction($transaction->state, $cancelTime, $transaction->id); + } + + if ($transaction->state != PaymeState::Done) { + return $this->successCancelTransaction($transaction->state, $transaction->cancel_time, $transaction->id); + } + + $this->withdrawBalance($transaction->user, $transaction->amount); + + $cancelTime = $this->microtime(); + + $transaction->update([ + "state" => PaymeState::Cancelled_After_Success, + "cancel_time" => $cancelTime, + "reason" => $reason + ]); + + return $this->successCancelTransaction($transaction->state, $cancelTime, $transaction->id); + } + + + /** + * @throws PaymeException + */ + public function CheckTransaction(){ + if(!$this->hasParam('id')) + { + throw new PaymeException(PaymeException::JSON_RPC_ERROR); + } + + $id = $this->params['id']; + + $transaction = PaymeTransaction::where('transaction', $id)->first(); + + if($transaction) + { + return $this->successCheckTransaction( + $transaction->create_time, + $transaction->perform_time, + $transaction->cancel_time, + $transaction->id, + $transaction->state, + $transaction->reason + ); + }else{ + throw new PaymeException(PaymeException::TRANS_NOT_FOUND); + } + } + + public function GetStatement(){ + // pass + } + + public function SetFiscalData(){ + // pass + } +} \ No newline at end of file diff --git a/src/Traits/JsonRPC.php b/src/Traits/JsonRPC.php new file mode 100644 index 0000000..7618ef4 --- /dev/null +++ b/src/Traits/JsonRPC.php @@ -0,0 +1,24 @@ +json([ + 'jsonrpc' => '2.0', + 'result' => $result, + ]); + } + + public function error($error): JsonResponse + { + return response()->json([ + 'jsonrpc' => '2.0', + 'error' => $error, + ]); + } +} \ No newline at end of file diff --git a/src/Traits/PaymeHelper.php b/src/Traits/PaymeHelper.php new file mode 100644 index 0000000..df98238 --- /dev/null +++ b/src/Traits/PaymeHelper.php @@ -0,0 +1,100 @@ +microtime() <= ($created_time + $this->timeout); + } + + public function isValidAmount($amount): bool + { + if ($amount < $this->minAmount || $amount > $this->maxAmount) { + return false; + } + + return true; + } + + public function successCreateTransaction($createTime, $transaction, $state) + { + return $this->success([ + 'create_time' => $createTime, + 'perform_time' => 0, + 'cancel_time' => 0, + 'transaction' => strval($transaction), + 'state' => $state, + 'reason' => null + ]); + } + + public function successCheckPerformTransaction() + { + return $this->success([ + "allow" => true + ]); + } + + public function successPerformTransaction($state, $performTime, $transaction) + { + return $this->success([ + "state" => $state, + "perform_time" => $performTime, + "transaction" => strval($transaction), + ]); + } + + public function successCheckTransaction($createTime, $performTime, $cancelTime, $transaction, $state, $reason) + { + return $this->success([ + "create_time" => $createTime ?? 0, + "perform_time" => $performTime ?? 0, + "cancel_time" => $cancelTime ?? 0, + "transaction" => strval($transaction), + "state" => $state, + "reason" => $reason + ]); + } + + public function successCancelTransaction($state, $cancelTime, $transaction) + { + return $this->success([ + "state" => $state, + "cancel_time" => $cancelTime, + "transaction" => strval($transaction) + ]); + } + + public function fillUpBalance($user, $amount): void + { + $user->balance += $amount; + $user->save(); + } + + public function withdrawBalance($user, $amount): void + { + $user->balance -= $amount; + $user->save(); + } + + public function hasParam($param): bool + { + if (is_array($param)) { + foreach ($param as $item) { + if(!$this->hasParam($item)) return false; + } + return true; + } else { + return isset($this->params[$param]) && !empty($this->params[$param]); + } + } +} \ No newline at end of file diff --git a/src/config/payme.php b/src/config/payme.php new file mode 100644 index 0000000..3d772ab --- /dev/null +++ b/src/config/payme.php @@ -0,0 +1,13 @@ + env('PAYME_MIN_AMOUNT', 1_000_00), + 'max_amount' => env('PAYME_MAX_AMOUNT', 100_000_000_00), + 'identity' => env('PAYME_IDENTITY', 'id'), + 'login' => env('PAYME_LOGIN', 'TestUser'), + 'key' => env('PAYME_KEY', 'TestKey'), + 'merchant_id' => env('PAYME_MERCHANT_ID', '123456789'), + 'allowed_ips' => [ + '185.178.51.131', '185.178.51.132', '195.158.31.134', '195.158.31.10', '195.158.28.124', '195.158.5.82', '127.0.0.1' + ] +]; \ No newline at end of file