diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f4e36ca --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..808f8c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +build +composer.lock +docs +vendor +coverage \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..dd33ce8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +All notable changes to `laravel-wallet` will be documented in this file + +## 1.0.0 - 2018-02-20 + +- initial release diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..7bfa0fc --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) WEBARTISAN + +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..8246cf2 --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# Laravel Wallet + +In a few projects I had to implement a virtual currency. The user would buy packs of credits with Stripe and then use them in the app in exchange of services or goods. +This package is a small and simple implementation of this concept with place for customization. + +## Installation + +Install the package with composer: + +```bash +composer require depsimon/laravel-wallet +``` + +## Run Migrations + +Publish the migrations with this artisan command: + +```bash +php artisan vendor:publish --provider="Depsimon\Wallet\WalletServiceProvider" --tag=migrations +``` + +## Configuration + +You can publish the config file with this artisan command: + +```bash +php artisan vendor:publish --provider="Depsimon\Wallet\WalletServiceProvider" --tag=config +``` + +This will merge the `wallet.php` config file where you can specify the Users, Wallets & Transactions classes if you have custom ones. + +## Usage + +Add the `HasWallet` trait to your User model. + +``` php + +use Depsimon\Wallet\HasWallet; + +class User extends Model +{ + use HasWallet; + + ... +} +``` + +Then you can easily make transactions from your user model. + +``` php +$user = User::find(1); +$user->balance; // 0 + +$user->deposit(100); +$user->balance; // 100 + +$user->withdraw(50); +$user->balance; // 50 + +$user->forceWithdraw(200); +$user->balance; // -150 +``` + +You can easily add meta information to the transactions to suit your needs. + +``` php +$user = User::find(1); +$user->deposit(100, 'deposit', ['stripe_source' => 'ch_BEV2Iih1yzbf4G3HNsfOQ07h', 'description' => 'Deposit of 100 credits from Stripe Payment']); +$user->withdraw(10, 'withdraw', ['description' => 'Purchase of Item #1234']); +``` + +### Security + +If you discover any security related issues, please email simon@webartisan.be instead of using the issue tracker. + +## Credits + +- [Simon Depelchin](https://github.com/depsimon) + +## License + +The MIT License (MIT). Please see [License File](LICENSE.md) for more information. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..24078d3 --- /dev/null +++ b/composer.json @@ -0,0 +1,49 @@ +{ + "name": "depsimon/laravel-wallet", + "description": "Easy to use virtual wallet for your app", + "keywords": [ + "depsimon", + "laravel-wallet", + "virtual", + "currency", + "credits", + "wallet" + ], + "homepage": "https://github.com/depsimon/laravel-wallet", + "license": "MIT", + "authors": [ + { + "name": "Simon Depelchin", + "email": "simon@webartisan.be", + "homepage": "https://webartisan.be", + "role": "Developer" + } + ], + "require": { + "php": "^7.0", + "illuminate/database": "~5.6" + }, + "autoload": { + "psr-4": { + "Depsimon\\Wallet\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Depsimon\\Wallet\\Tests\\": "tests" + } + }, + "config": { + "sort-packages": true + }, + "extra": { + "laravel": { + "providers": [ + "Depsimon\\Wallet\\WalletServiceProvider" + ], + "aliases": { + "Wallet": "Depsimon\\Wallet\\WalletFacade" + } + } + } +} diff --git a/config/.gitkeep b/config/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/config/config.php b/config/config.php new file mode 100644 index 0000000..dd4035a --- /dev/null +++ b/config/config.php @@ -0,0 +1,20 @@ + 'App\User', + + /** + * Change this if you extend the default Wallet Model + */ + 'wallet_model' => 'Depsimon\Wallet\Wallet', + + /** + * Change this if you extend the default Transaction Model + */ + 'transaction_model' => 'Depsimon\Wallet\Transaction', + +]; \ No newline at end of file diff --git a/resources/migrations/2018_02_20_113000_create_wallets_table.php b/resources/migrations/2018_02_20_113000_create_wallets_table.php new file mode 100644 index 0000000..1a0821e --- /dev/null +++ b/resources/migrations/2018_02_20_113000_create_wallets_table.php @@ -0,0 +1,42 @@ +increments('id'); + $table->unsignedInteger($userClass->getForeignKey())->nullable(); + + $table->bigInteger('balance'); + + $table->timestamps(); + + $table->foreign($userClass->getForeignKey()) + ->references($userClass->getKeyName()) + ->on($userClass->getTable()) + ->onDelete('set null'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('wallets'); + } +} diff --git a/resources/migrations/2018_02_20_113500_create_wallet_transactions_table.php b/resources/migrations/2018_02_20_113500_create_wallet_transactions_table.php new file mode 100644 index 0000000..1729555 --- /dev/null +++ b/resources/migrations/2018_02_20_113500_create_wallet_transactions_table.php @@ -0,0 +1,41 @@ +increments('id'); + $table->unsignedInteger('wallet_id'); + + $table->integer('amount'); // amount is an integer, it could be "dollars" or "cents" + $table->string('hash', 60); // hash is a uniqid for each transaction + $table->string('type', 30); // type can be anything in your app, by default we use "deposit" and "withdraw" + $table->boolean('accepted'); // All transactions will be added in the book, some can be refused + $table->json('meta')->nullable(); // Add all kind of meta information you need + + $table->timestamps(); + + $table->foreign('wallet_id')->references('id')->on('wallets')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('wallet_transactions'); + } +} diff --git a/src/HasWallet.php b/src/HasWallet.php new file mode 100644 index 0000000..6b183db --- /dev/null +++ b/src/HasWallet.php @@ -0,0 +1,117 @@ +wallet->balance; + } + + /** + * Retrieve the wallet of this user + */ + public function wallet() + { + return $this->hasOne(config('wallet.wallet_model', Wallet::class))->withDefault(); + } + + /** + * Retrieve all transactions of this user + */ + public function transactions() + { + return $this->hasManyThrough(config('wallet.transaction_model', Transaction::class), config('wallet.wallet_model', Wallet::class))->latest(); + } + + /** + * Determine if the user can withdraw the given amount + * @param integer $amount + * @return boolean + */ + public function canWithdraw($amount) + { + return $this->balance >= $amount; + } + + /** + * Move credits to this account + * @param integer $amount + * @param string $type + * @param array $meta + */ + public function deposit($amount, $type = 'deposit', $meta = []) + { + $this->balance += $amount; + $this->save(); + + $this->transactions() + ->create([ + 'amount' => $amount, + 'hash' => uniqid('lwch_'), + 'type' => $type, + 'accepted' => true, + 'meta' => $meta + ]); + } + + /** + * Attempt to move credits from this account + * @param integer $amount + * @param string $type + * @param array $meta + * @param boolean $shouldAccept + */ + public function withdraw($amount, $type = 'withdraw', $meta = [], $shouldAccept = true) + { + $accepted = $shouldAccept ? $this->canWithdraw($amount) : true; + + if ($accepted) { + $this->balance += $amount; + $this->save(); + } + + $this->transactions() + ->create([ + 'amount' => $amount, + 'hash' => uniqid('lwch_'), + 'type' => $type, + 'accepted' => $accepted, + 'meta' => $meta + ]); + } + + /** + * Move credits from this account + * @param integer $amount + * @param string $type + * @param array $meta + * @param boolean $shouldAccept + */ + public function forceWithdraw($amount, $type = 'withdraw', $meta = []) + { + return $this->withdraw($amount, $type, $meta, false); + } + + /** + * Returns the actual balance for this wallet. + * Might be different from the balance property if the database is manipulated + * @return float balance + */ + public function actualBalance() + { + $credits = $this->transactions() + ->whereIn('type', ['deposit', 'refund']) + ->sum('amount'); + + $debits = $this->transactions() + ->whereIn('type', ['withdraw', 'payout']) + ->sum('amount'); + + return $credits - $debits; + } +} \ No newline at end of file diff --git a/src/Transaction.php b/src/Transaction.php new file mode 100644 index 0000000..b342809 --- /dev/null +++ b/src/Transaction.php @@ -0,0 +1,38 @@ + 'float', + 'meta' => 'json' + ]; + + /** + * Retrieve the wallet from this transaction + */ + public function wallet() + { + return $this->belongsTo(config('wallet.wallet_model', Wallet::class)); + } + + /** + * Retrieve the amount with the positive or negative sign + */ + public function getAmountWithSignAttribute() + { + return in_array($this->type, ['deposit', 'refund']) + ? '+' . $this->amount + : '-' . $this->amount; + } + +} \ No newline at end of file diff --git a/src/Wallet.php b/src/Wallet.php new file mode 100644 index 0000000..1640f27 --- /dev/null +++ b/src/Wallet.php @@ -0,0 +1,18 @@ +hasMany(config('wallet.transaction_model', Transaction::class)); + } + +} \ No newline at end of file diff --git a/src/WalletFacade.php b/src/WalletFacade.php new file mode 100644 index 0000000..d9d26c4 --- /dev/null +++ b/src/WalletFacade.php @@ -0,0 +1,21 @@ +app->runningInConsole()) { + $this->publishes([ + __DIR__ . '/../config/config.php' => config_path('wallet.php'), + ], 'config'); + + $this->publishes([ + __DIR__ . '/../resources/migrations/' => database_path('migrations'), + ], 'migrations'); + } + } + + /** + * Register the application services. + */ + public function register() + { + $this->mergeConfigFrom(__DIR__ . '/../config/config.php', 'wallet'); + } +} diff --git a/tests/ExampleTest.php b/tests/ExampleTest.php new file mode 100644 index 0000000..b91f824 --- /dev/null +++ b/tests/ExampleTest.php @@ -0,0 +1,14 @@ +assertTrue(true); + } +}