diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index f69c0b1..7a8a2f3 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -46,6 +46,3 @@ jobs: - name: Execute tests run: composer test - - # - name: Execute tests with hashids turned on - # run: composer test-hashids diff --git a/README.md b/README.md index bdcdf6b..3789b5b 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,33 @@ ## What's going on here? This is a reward system package for [Lunar](https://github.com/lunarphp/lunar) -which allows your users to earn points for their purchases and redeem them for discounts. +which allows you to add or subtract reward points from models (eg. `User`s). +You can give points to users for various actions like buying products, writing reviews, etc. +Your users can then spend these points on discounts, free products, etc. + +Point balances are managed by the awesome [Laravel Wallet](https://github.com/021-projects/laravel-wallet) package. + +### Example use cases + +#### Getting rewards + +```diff ++ Give points to a user for every paid order (can be calculated from order value, or fixed amount) ++ Give points to a user for writing a review ++ Give points to a user for referring a friend ++ Give points to a user for signing up ++ Give points to a user for completing a profile +``` + +#### Spending rewards + +```diff +- Donating points to a charity (transfer points from user to a dedicated charity account) +- Redeeming points for a discount (applied on cart) +- Redeeming points for a coupon code with the value of points +- Moving users to a different customer group based on points +- Giving a free product for a certain amount of points +``` ## Getting started guide @@ -27,12 +53,15 @@ composer require dystcz/lunar-rewards Publish config files -> You will probably need them pretty bad - ```bash -php artisan vendor:publish --provider="Dystcz\LunarRewards\LunarRewardsServiceProvider" --tag="lunar-rewards" +php artisan vendor:publish --provider="Dystcz\LunarRewards\LunarRewardsServiceProvider" --tag="lunar-rewards.config" ``` +This will publish two configuration files: + +1. `config/lunar-rewards/rewards.php` - contains the rewards configuration +2. `config/wallet.php` - contains the wallet configuration + Publish migrations > Only in case you want to customize the database schema @@ -41,6 +70,140 @@ Publish migrations php artisan vendor:publish --provider="Dystcz\LunarRewards\LunarRewardsServiceProvider" --tag="lunar-rewards.migrations" ``` +### Configuration + +If you want to dig deeper into the underlaying [laravel-wallet](https://github.com/021-projects/laravel-wallet) package configuration +please visit their [documentation](021-projects.github.io/laravel-wallet). +You might want to [configure the database table names](https://021-projects.github.io/laravel-wallet/8.x/configuration.html#table-names). + +### Usage + +#### Preparing your models + +1. Implement the `Rewardable` interface in your model. +2. Add the `HasRewardPointsBalance` trait to your model. + +```php +use Dystcz\LunarRewards\Domain\Rewards\Contracts\Rewardable; +use Dystcz\LunarRewards\Domain\Rewards\Traits\HasRewardPointsBalance; + +class Model +class Model implements Rewardable +{ + use HasRewardPointsBalance; +} +``` + +#### Depositing / Giving points to a model + +```php +use Dystcz\LunarRewards\Domain\Rewards\Actions\DepositPoints; +use Dystcz\LunarRewards\Domain\Rewards\DataTypes\Reward; +use Dystcz\LunarRewards\Facades\LunarRewards; + +(new DepositPoints)->handle(to: $model, points: new Reward(100)); + +// or by calling the facade +LunarRewards::deposit(to: $model, points: new Reward(1000)); +``` + +#### Charging points from a model + +```php +use Dystcz\LunarRewards\Domain\Rewards\Actions\ChargePoints; +use Dystcz\LunarRewards\Domain\Rewards\DataTypes\Reward; +use Dystcz\LunarRewards\Facades\LunarRewards; + +(new ChargePoints)->handle(from: $model, points: new Reward(100)); + +// or by calling the facade +LunarRewards::charge(from: $model, points: new Reward(1000)); +``` + +#### Transferring points + +```php +use Dystcz\LunarRewards\Domain\Rewards\Actions\TransferPoints; +use Dystcz\LunarRewards\Domain\Rewards\DataTypes\Reward; +use Dystcz\LunarRewards\Facades\LunarRewards; + +(new TransferPoints)->handle(from: $model, to: $model2, points: new Reward(100)); + +// or by calling the facade +LunarRewards::transfer(from: $model, to: $model2, points: new Reward(1000)); +``` + +#### Getting model points balance + +```php +use Dystcz\LunarRewards\Domain\Rewards\Managers\PointBalanceManager; +use Dystcz\LunarRewards\Facades\LunarRewards; + +$balance = PointBalanceManager::of($model); + +// Points Balance +$balance->getValue(); // int +$balance->getReward(); // Dystcz\LunarRewards\Domain\Rewards\DataTypes\Reward + +// Get balance by calling the facade +LunarRewards::balance($model); // int + +// All Sent Points +$balance->getSent(); // int +$balance->getSentReward(); // Dystcz\LunarRewards\Domain\Rewards\DataTypes\Reward + +// All Received Points +$balance->getReceived(); // int +$balance->getReceivedReward(); // Dystcz\LunarRewards\Domain\Rewards\DataTypes\Reward +``` + +#### Getting model points transactions + +```php +use Dystcz\LunarRewards\Domain\Rewards\Managers\PointBalanceManager; + +$balance = PointBalanceManager::of($model); + +// All Received Points +$balance->getTransactions(); // Illuminate\Support\Collection<\Dystcz\LunarRewards\Domain\Rewards\Models\Transaction> +$balance->getTransactionsQuery(); // Illuminate\Database\Eloquent\Builder + +// Or simply by calling the facade +``` + +#### Validating balances + +```php +use Dystcz\LunarRewards\Domain\Rewards\Managers\PointBalanceManager; +use Dystcz\LunarRewards\Domain\Rewards\DataTypes\Reward; + +$balance = PointBalanceManager::of($model); + +// Check if model has enough points +$balance->hasEnoughPoints(new Reward(1000)); // bool +``` + +#### Creating coupons from balance + +```php +use Dystcz\LunarRewards\Domain\Rewards\Actions\CreateCouponFromBalance; +use Dystcz\LunarRewards\Domain\Rewards\DataTypes\Reward; + +$currency = $order->currency; // Lunar\Models\Currency + +// Create a coupon with the value from the whole balance +$coupon = App::make(CreateCouponFromBalance::class)->handle(model: $model, currency: $currency); + +// Create a coupon only for provided points +$coupon = App::make(CreateCouponFromBalance::class)->handle( + model: $model, + currency: $currency, + points: new Reward(1000) +); +``` + +### Lunar API endpoints + ### Testing ```bash @@ -67,7 +230,7 @@ If you discover any security related issues, please email dev@dy.st instead of u - [All Contributors](../../contributors) - [Lunar](https://github.com/lunarphp/lunar) for providing awesome e-commerce package -- [Laravel JSON:API](https://github.com/laravel-json-api/laravel) +- [Laravel Wallet](https://github.com/021-projects/laravel-wallet) for the points transaction engine which is a brilliant JSON:API layer for Laravel applications ## License diff --git a/composer.json b/composer.json index 0ec19e1..b9f5790 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,7 @@ ], "require": { "php": "^8.2", + "021/laravel-wallet": "^8.2", "illuminate/support": "^10.0", "lunarphp/lunar": "^0.8" }, @@ -31,14 +32,15 @@ }, "require-dev": { "barryvdh/laravel-ide-helper": "^2.13", + "driftingly/rector-laravel": "^0.17.0", + "dystcz/lunar-api": "^0.8", "laravel-json-api/testing": "^2.1", + "laravel/facade-documenter": "dev-main", "laravel/pint": "^1.7", - "dystcz/lunar-api": "^0.8", "orchestra/testbench": "^8.0", "pestphp/pest": "^2.0", "pestphp/pest-plugin-laravel": "^2.0", "rector/rector": "^0.15.23", - "driftingly/rector-laravel": "^0.17.0", "spatie/laravel-ray": "^1.32" }, "autoload": { @@ -56,7 +58,6 @@ "clear": "@php vendor/bin/testbench package:purge --ansi", "prepare": "@php vendor/bin/testbench package:discover --ansi", "test": "vendor/bin/pest", - "test-hashids": "vendor/bin/pest -c phpunit.hashids.xml", "test-coverage": "vendor/bin/pest --coverage", "analyse": "vendor/bin/phpstan analyse", "format": "vendor/bin/pint" @@ -78,5 +79,11 @@ } }, "minimum-stability": "dev", - "prefer-stable": true + "prefer-stable": true, + "repositories": { + "facade-documenter": { + "type": "vcs", + "url": "git@github.com:laravel/facade-documenter.git" + } + } } diff --git a/composer.lock b/composer.lock index 9e93be9..b5826b7 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,124 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "721071caf4082d4d694da455432f4771", + "content-hash": "6c17a8a017dae78b9733e98af130b806", "packages": [ + { + "name": "021/laravel-wallet", + "version": "v8.2.6", + "source": { + "type": "git", + "url": "https://github.com/021-projects/laravel-wallet.git", + "reference": "c66401a1682fce12a2ca0577eead7d7562b73585" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/021-projects/laravel-wallet/zipball/c66401a1682fce12a2ca0577eead7d7562b73585", + "reference": "c66401a1682fce12a2ca0577eead7d7562b73585", + "shasum": "" + }, + "require": { + "021/safely-transaction": "^1.0.1", + "ext-bcmath": "*", + "laravel/framework": "^10.0|^11.0", + "php": "^8.1|^8.2|^8.3" + }, + "require-dev": { + "larastan/larastan": "^2.0", + "laravel/pint": "^1.13", + "orchestra/testbench": "^8.21|^9.0", + "phpunit/phpunit": "^10.5" + }, + "type": "package", + "extra": { + "laravel": { + "providers": [ + "O21\\LaravelWallet\\ServiceProvider" + ], + "dont-discover": [] + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "O21\\LaravelWallet\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "021", + "email": "devsellio@gmail.com", + "homepage": "https://021-projects.github.io/", + "role": "Developer" + } + ], + "description": "Reliable and flexible wallet system for Laravel", + "keywords": [ + "balance", + "deposit", + "finance", + "laravel", + "payment", + "payout", + "transactions", + "transfer", + "wallet", + "withdrawal" + ], + "support": { + "issues": "https://github.com/021-projects/laravel-wallet/issues", + "source": "https://github.com/021-projects/laravel-wallet/tree/v8.2.6" + }, + "time": "2024-03-17T08:41:53+00:00" + }, + { + "name": "021/safely-transaction", + "version": "v1.0.3", + "source": { + "type": "git", + "url": "https://github.com/021-projects/laravel-safely-transaction.git", + "reference": "3036089425805bc29f6da9b88dc16896ff67c400" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/021-projects/laravel-safely-transaction/zipball/3036089425805bc29f6da9b88dc16896ff67c400", + "reference": "3036089425805bc29f6da9b88dc16896ff67c400", + "shasum": "" + }, + "require": { + "illuminate/database": "^7.0|^8.0|^9.0|^10.0|^11.0", + "php": "^8.0|^8.1|^8.2" + }, + "require-dev": { + "phpunit/phpunit": "^9.5.10" + }, + "type": "library", + "autoload": { + "psr-4": { + "O21\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Helper class for locking record in database", + "keywords": [ + "framework", + "laravel" + ], + "support": { + "issues": "https://github.com/021-projects/laravel-safely-transaction/issues", + "source": "https://github.com/021-projects/laravel-safely-transaction/tree/v1.0.3" + }, + "time": "2024-02-22T19:29:46+00:00" + }, { "name": "barryvdh/laravel-dompdf", "version": "v2.1.1", @@ -9506,6 +9622,37 @@ }, "time": "2023-02-14T19:18:31+00:00" }, + { + "name": "laravel/facade-documenter", + "version": "dev-main", + "source": { + "type": "git", + "url": "https://github.com/laravel/facade-documenter.git", + "reference": "57c6f78e684351902d5b8d0166cc13e33f4d73e6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/facade-documenter/zipball/57c6f78e684351902d5b8d0166cc13e33f4d73e6", + "reference": "57c6f78e684351902d5b8d0166cc13e33f4d73e6", + "shasum": "" + }, + "require": { + "illuminate/support": "^9.51|^10.0|^11.0|^12.0", + "phpstan/phpdoc-parser": "^1.16" + }, + "default-branch": true, + "bin": [ + "facade.php" + ], + "type": "library", + "license": [ + "MIT" + ], + "support": { + "source": "https://github.com/laravel/facade-documenter/tree/main" + }, + "time": "2024-03-12T17:06:24+00:00" + }, { "name": "laravel/pint", "version": "v1.15.1", @@ -13424,7 +13571,9 @@ ], "aliases": [], "minimum-stability": "dev", - "stability-flags": [], + "stability-flags": { + "laravel/facade-documenter": 20 + }, "prefer-stable": true, "prefer-lowest": false, "platform": { diff --git a/config/rewards.php b/config/rewards.php index dc0e829..7f268a9 100644 --- a/config/rewards.php +++ b/config/rewards.php @@ -17,8 +17,41 @@ |-------------------------------------------------------------------------- | | Specify the coefficient to use for calculating reward points. - | The coefficient multiplies the order total to calculate the reward points. + | The coefficient multiplies the value to calculate the reward points. + | Default: sub total * reward point coefficient (10) | */ - 'reward_point_coefficient' => 1, + 'reward_coefficient' => 10, + + /* + |-------------------------------------------------------------------------- + | Reward Value Coefficient + |-------------------------------------------------------------------------- + | + | Specify the coefficient to use for calculating value from reward points. + | The coefficient divides the reward points to calculate the value. + | Default: points / (reward point coefficient * value coefficient) (10 * 10 = 100) + | + */ + 'value_coefficient' => 10, + + /* + |-------------------------------------------------------------------------- + | Coupon Code Generator + |-------------------------------------------------------------------------- + | + | Specify which class to for generating discount codes. + | + */ + 'coupon_code_generator' => \Dystcz\LunarRewards\Domain\Discounts\Generators\CouponCodeGenerator::class, + + /* + |-------------------------------------------------------------------------- + | Coupon Name Prefix + |-------------------------------------------------------------------------- + | + | Set the prefix for the coupon code name. + | + */ + 'coupon_name_prefix' => 'Reward Points Coupon', ]; diff --git a/config/wallet.php b/config/wallet.php new file mode 100644 index 0000000..d74eefc --- /dev/null +++ b/config/wallet.php @@ -0,0 +1,48 @@ + 'RP', + + 'balance' => [ + 'accounting_statuses' => [ + \O21\LaravelWallet\Enums\TransactionStatus::SUCCESS, + \O21\LaravelWallet\Enums\TransactionStatus::ON_HOLD, + ], + 'extra_values' => [ + // enable value_pending calculation + 'pending' => false, + // enable value_on_hold calculation + 'on_hold' => false, + ], + 'max_scale' => 8, + 'log_states' => false, + ], + + 'models' => [ + 'balance' => \Dystcz\LunarRewards\Domain\Rewards\Models\Balance::class, + 'balance_state' => \Dystcz\LunarRewards\Domain\Rewards\Models\BalanceState::class, + 'transaction' => \Dystcz\LunarRewards\Domain\Rewards\Models\Transaction::class, + ], + + /* + |-------------------------------------------------------------------------- + | Table Names + |-------------------------------------------------------------------------- + | + | Specify the table names to use for the rewards system. + | Note that the table names are prefixed with the default Lunar table prefix. + | Eg. 'balances' will be 'lunar_balances' or wharever your table prefix is. + | + */ + 'table_names' => [ + 'balances' => 'wallet_balances', + 'balance_states' => 'wallet_balance_states', + 'transactions' => 'wallet_transactions', + ], + + 'processors' => [ + 'deposit' => \O21\LaravelWallet\Transaction\Processors\DepositProcessor::class, + 'charge' => \O21\LaravelWallet\Transaction\Processors\ChargeProcessor::class, + 'transfer' => \O21\LaravelWallet\Transaction\Processors\TransferProcessor::class, + ], +]; diff --git a/lang/cs/validations.php b/lang/cs/validations.php index 483a4fd..3ac44ad 100644 --- a/lang/cs/validations.php +++ b/lang/cs/validations.php @@ -1,209 +1,5 @@ [ - 'company_in' => [ - 'string' => 'IČO společnosti musí být řetězec.', - ], - 'company_tin' => [ - 'string' => 'Pole DIČ společnosti musí být řetězec.', - ], - 'line_one' => [ - 'required' => 'Pole první řádek je povinné.', - 'string' => 'Pole první řádek musí být řetězec.', - ], - 'line_two' => [ - 'string' => 'Pole druhý řádek musí být řetězec.', - ], - 'line_three' => [ - 'string' => 'Pole třetí řádek musí být řetězec.', - ], - 'city' => [ - 'required' => 'Pole město je povinné.', - 'string' => 'Pole město musí být řetězec.', - ], - 'state' => [ - 'string' => 'Pole stát musí být řetězec.', - ], - 'postcode' => [ - 'required' => 'Pole PSČ je povinné.', - 'string' => 'Pole PSČ musí být řetězec.', - ], - 'delivery_instructions' => [ - 'string' => 'Pole instrukce pro doručení musí být řetězec.', - ], - 'contact_email' => [ - 'string' => 'Pole kontaktní e-mail musí být řetězec.', - ], - 'contact_phone' => [ - 'string' => 'Pole kontaktní telefon musí být řetězec.', - ], - 'shipping_default' => [ - 'boolean' => 'Pole výchozí doručení musí být logická hodnota.', - ], - 'billing_default' => [ - 'boolean' => 'Pole výchozí fakturace musí být logická hodnota.', - ], - 'meta' => [ - 'array' => 'Pole meta musí být pole.', - ], - ], - - 'carts' => [ - 'create_user' => [ - 'boolean' => 'Pole vytvořit uživatele musí být logická hodnota.', - ], - 'meta' => [ - 'array' => 'Pole meta musí být pole.', - ], - 'coupon_code' => [ - 'required' => 'Pole kód kupónu je povinné.', - 'string' => 'Pole kód kupónu musí být řetězec.', - 'invalid' => 'Kupón není platný nebo byl použit příliš mnohokrát.', - ], - 'agree' => [ - 'accepted' => 'Musíte souhlasit s obchodními podmínkami.', - ], - 'shipping_option' => [ - 'required' => 'Prosím vyberte možnost doručení.', - ], - ], - - 'cart_addresses' => [ - 'title' => [ - 'string' => 'Pole titul musí být řetězec.', - ], - 'first_name' => [ - 'required' => 'Pole jméno je povinné.', - 'string' => 'Pole jméno musí být řetězec.', - ], - 'last_name' => [ - 'required' => 'Pole příjmení je povinné.', - 'string' => 'Pole příjmení musí být řetězec.', - ], - 'company_name' => [ - 'string' => 'Pole název společnosti musí být řetězec.', - ], - 'company_in' => [ - 'string' => 'Pole IČO společnosti musí být řetězec.', - ], - 'company_tin' => [ - 'string' => 'Pole DIČ společnosti musí být řetězec.', - ], - 'line_one' => [ - 'required' => 'Pole první řádek je povinné.', - 'string' => 'Pole první řádek musí být řetězec.', - ], - 'line_two' => [ - 'string' => 'Pole druhý řádek musí být řetězec.', - ], - 'line_three' => [ - 'string' => 'Pole třetí řádek musí být řetězec.', - ], - 'city' => [ - 'required' => 'Pole město je povinné.', - 'string' => 'Pole město musí být řetězec.', - ], - 'state' => [ - 'string' => 'Pole stát musí být řetězec.', - ], - 'postcode' => [ - 'required' => 'Pole PSČ je povinné.', - 'string' => 'Pole PSČ musí být řetězec.', - ], - 'delivery_instructions' => [ - 'string' => 'Pole instrukce pro doručení musí být řetězec.', - ], - 'contact_email' => [ - 'string' => 'Pole kontaktní e-mail musí být řetězec.', - ], - 'contact_phone' => [ - 'string' => 'Pole kontaktní telefon musí být řetězec.', - ], - 'shipping_option' => [ - 'string' => 'Pole možnost doručení musí být řetězec.', - ], - 'address_type' => [ - 'required' => 'Pole typ adresy je povinné.', - 'string' => 'Pole typ adresy musí být řetězec.', - 'in' => 'Pole typ adresy musí být jedno z: :values.', - ], - ], - - 'cart_lines' => [ - 'quantity' => [ - 'integer' => 'Pole množství musí být celé číslo.', - ], - 'purchasable_id' => [ - 'required' => 'Pole ID položky je povinné.', - 'integer' => 'Pole ID položky musí být celé číslo.', - ], - 'purchasable_type' => [ - 'required' => 'Pole typ položky je povinné.', - 'string' => 'Pole typ položky musí být řetězec.', - ], - 'meta' => [ - 'array' => 'Pole meta musí být pole.', - ], - ], - - 'customers' => [ - 'title' => [ - 'string' => 'Pole titul musí být řetězec.', - ], - 'first_name' => [ - 'string' => 'Pole jméno musí být řetězec.', - ], - 'last_name' => [ - 'string' => 'Pole příjmení musí být řetězec.', - ], - 'company_name' => [ - 'string' => 'Pole název společnosti musí být řetězec.', - ], - 'vat_no' => [ - 'string' => 'DIČ společnosti musí být řetězec.', - ], - 'account_ref' => [ - 'string' => 'IČO společnosti musí být řetězec.', - ], - ], - - 'orders' => [ - 'notes' => [ - 'string' => 'Pole poznámky musí být řetězec.', - ], - ], - - 'payments' => [ - 'payment_method' => [ - 'required' => 'Pole způsob platby je povinné.', - 'string' => 'Pole způsob platby musí být řetězec.', - 'in' => 'Pole způsob platby musí být jedno z: :types.', - ], - 'amount' => [ - 'numeric' => 'Pole částka musí být číslo.', - ], - 'meta' => [ - 'array' => 'Pole meta musí být pole.', - ], - ], - - 'shipping' => [ - 'set_shipping_option' => [ - 'shipping_option' => [ - 'required' => 'Prosím vyberte možnost doručení.', - 'string' => 'Pole možnost doručení musí být řetězec.', - ], - ], - ], - - 'payments' => [ - 'set_payment_option' => [ - 'payment_option' => [ - 'required' => 'Prosím vyberte platební metodu.', - 'string' => 'Pole platební metoda musí být řetězec.', - ], - ], - ], + // ]; diff --git a/lang/en/validations.php b/lang/en/validations.php index 505c591..3ac44ad 100644 --- a/lang/en/validations.php +++ b/lang/en/validations.php @@ -1,227 +1,5 @@ [ - 'email' => [ - 'required' => 'Please enter your email address.', - 'email' => 'Please enter a valid email address.', - 'unique' => 'This email address is already in use.', - 'max' => 'Your email address must be less than :max characters long.', - ], - - 'password' => [ - 'required' => 'Please enter a password.', - 'min' => 'Your password must be at least :min characters long.', - 'confirmed' => 'Please confirm your password.', - ], - ], - - 'addresses' => [ - 'company_in' => [ - 'string' => 'Company ID must be a string.', - ], - 'company_tin' => [ - 'string' => 'Company tax ID field must be a string.', - ], - 'line_one' => [ - 'required' => 'Line one field is required.', - 'string' => 'Line one field must be a string.', - ], - 'line_two' => [ - 'string' => 'Line two field must be a string.', - ], - 'line_three' => [ - 'string' => 'Line three field must be a string.', - ], - 'city' => [ - 'required' => 'City field is required.', - 'string' => 'City field must be a string.', - ], - 'state' => [ - 'string' => 'State field must be a string.', - ], - 'postcode' => [ - 'required' => 'Postcode field is required.', - 'string' => 'Postcode field must be a string.', - ], - 'delivery_instructions' => [ - 'string' => 'Delivery instructions field must be a string.', - ], - 'contact_email' => [ - 'string' => 'Contact email field must be a string.', - ], - 'contact_phone' => [ - 'string' => 'Contact phone field must be a string.', - ], - 'shipping_default' => [ - 'boolean' => 'Shipping default field must be a boolean.', - ], - 'billing_default' => [ - 'boolean' => 'Billing default field must be a boolean.', - ], - 'meta' => [ - 'array' => 'Meta field must be an array.', - ], - ], - - 'carts' => [ - 'create_user' => [ - 'boolean' => 'Create user field must be a boolean.', - ], - 'meta' => [ - 'array' => 'Meta field must be an array.', - ], - 'coupon_code' => [ - 'required' => 'Coupon code field is required.', - 'string' => 'Coupon code field must be a string.', - 'invalid' => 'The coupon is not valid or has been used too many times', - ], - 'agree' => [ - 'accepted' => 'You must agree to the terms and conditions.', - ], - 'shipping_option' => [ - 'required' => 'Please select a shipping option.', - ], - 'payment_option' => [ - 'required' => 'Please select a payment option.', - ], - ], - - 'cart_addresses' => [ - 'title' => [ - 'string' => 'Title field must be a string.', - ], - 'first_name' => [ - 'required' => 'First name field is required.', - 'string' => 'First name field must be a string.', - ], - 'last_name' => [ - 'required' => 'Last name field is required.', - 'string' => 'Last name field must be a string.', - ], - 'company_name' => [ - 'string' => 'Company name field must be a string.', - ], - 'company_in' => [ - 'string' => 'Company in field must be a string.', - ], - 'company_tin' => [ - 'string' => 'Company tin field must be a string.', - ], - 'line_one' => [ - 'required' => 'Line one field is required.', - 'string' => 'Line one field must be a string.', - ], - 'line_two' => [ - 'string' => 'Line two field must be a string.', - ], - 'line_three' => [ - 'string' => 'Line three field must be a string.', - ], - 'city' => [ - 'required' => 'City field is required.', - 'string' => 'City field must be a string.', - ], - 'state' => [ - 'string' => 'State field must be a string.', - ], - 'postcode' => [ - 'required' => 'Postcode field is required.', - 'string' => 'Postcode field must be a string.', - ], - 'delivery_instructions' => [ - 'string' => 'Delivery instructions field must be a string.', - ], - 'contact_email' => [ - 'string' => 'Contact email field must be a string.', - ], - 'contact_phone' => [ - 'string' => 'Contact phone field must be a string.', - ], - 'shipping_option' => [ - 'string' => 'Shipping option field must be a string.', - ], - 'address_type' => [ - 'required' => 'Address type field is required.', - 'string' => 'Address type field must be a string.', - 'in' => 'Address type field must be one of: :values.', - ], - ], - - 'cart_lines' => [ - 'quantity' => [ - 'integer' => 'Quantity field must be an integer.', - ], - 'purchasable_id' => [ - 'required' => 'Purchasable id field is required.', - 'integer' => 'Purchasable id field must be an integer.', - ], - 'purchasable_type' => [ - 'required' => 'Purchasable type field is required.', - 'string' => 'Purchasable type field must be a string.', - ], - 'meta' => [ - 'array' => 'Meta field must be an array.', - ], - ], - - 'customers' => [ - 'title' => [ - 'string' => 'Title field must be a string.', - ], - 'first_name' => [ - 'string' => 'First name field must be a string.', - ], - 'last_name' => [ - 'string' => 'Last name field must be a string.', - ], - 'company_name' => [ - 'string' => 'Company name field must be a string.', - ], - 'vat_no' => [ - 'string' => 'Company tax ID must be a string.', - ], - 'account_ref' => [ - 'string' => 'Company ID must be a string.', - ], - ], - - 'orders' => [ - 'notes' => [ - 'string' => 'Notes field must be a string.', - ], - ], - - 'payments' => [ - 'payment_method' => [ - 'required' => 'Payment method field is required.', - 'string' => 'Payment method field must be a string.', - 'in' => 'Payment method field must be one of: :types.', - ], - 'amount' => [ - 'numeric' => 'Amount field must be numeric.', - ], - 'meta' => [ - 'array' => 'Meta field must be an array.', - ], - ], - - 'shipping' => [ - 'set_shipping_option' => [ - 'shipping_option' => [ - 'required' => 'Please select a shipping option.', - 'string' => 'Shipping option field must be a string.', - ], - ], - ], - - 'payments' => [ - 'set_payment_option' => [ - 'payment_option' => [ - 'required' => 'Please select a payment method.', - 'string' => 'Payment method field must be a string.', - ], - ], - ], + // ]; diff --git a/phpunit.hashids.xml b/phpunit.hashids.xml deleted file mode 100644 index f52fc73..0000000 --- a/phpunit.hashids.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - ./tests/Unit - - - ./tests/Feature - - - - - - - - - - - - - ./src - - - diff --git a/src/Domain/Discounts/Contracts/CouponCodeGenerator.php b/src/Domain/Discounts/Contracts/CouponCodeGenerator.php new file mode 100644 index 0000000..c18f503 --- /dev/null +++ b/src/Domain/Discounts/Contracts/CouponCodeGenerator.php @@ -0,0 +1,11 @@ +generateCode(); + + $exists = Discount::query() + ->where('coupon', $code) + ->exists(); + } + + return $code; + } + + /** + * Generate code. + */ + protected function generateCode(): string + { + return Str::upper(Str::random(static::LENGTH)); + } + + /** + * Static constructor. + */ + public static function of(Model $model): self + { + return new static($model); + } + + /** + * Get model. + */ + public function getModel(): ?Model + { + return $this->model; + } +} diff --git a/src/Domain/Rewards/Actions/ChargePoints.php b/src/Domain/Rewards/Actions/ChargePoints.php new file mode 100644 index 0000000..cfbde6f --- /dev/null +++ b/src/Domain/Rewards/Actions/ChargePoints.php @@ -0,0 +1,25 @@ +transactionCreator + ->amount($points->value) + ->currency($points->currency->code) + ->processor('charge') + ->from($from) + ->commit(); + + return $transaction; + } +} diff --git a/src/Domain/Rewards/Actions/CreateCouponFromBalance.php b/src/Domain/Rewards/Actions/CreateCouponFromBalance.php new file mode 100644 index 0000000..40f341e --- /dev/null +++ b/src/Domain/Rewards/Actions/CreateCouponFromBalance.php @@ -0,0 +1,85 @@ +defaultCurrency = $this->getDefaultCurrency(); + } + + /** + * Create coupon from reward points. + */ + public function handle(Rewardable $model, Currency $currency, ?Reward $points = null): Discount + { + $points = $points ?? LunarRewards::balanceManager($model)->getReward(); + + $price = $this->calculateCouponValue($points, $currency); + + $coupon = $this->createCoupon($model, $price); + + return $coupon; + } + + /** + * Calculate coupon value. + */ + protected function calculateCouponValue(Reward $points, Currency $currency): Price + { + return RewardValueCalculator::for($points, $currency) + ->calculate(); + } + + /** + * Get default currency. + */ + protected function getDefaultCurrency(): Currency + { + return Currency::getDefault(); + } + + /** + * Create coupon. + */ + protected function createCoupon(Rewardable $model, Price $price): Discount + { + $prefix = trim(Config::get('lunar-rewards.rewards.coupon_name_prefix', 'Reward Points')); + $suffix = "for {$model->getMorphClass()}::{$model->getKey()}"; + $name = implode(' ', [$prefix, $suffix]); + $code = $this->generator->generate(); + + $discount = Discount::create([ + 'type' => AmountOff::class, + 'name' => $name, + 'max_uses' => 1, + 'handle' => $code, + 'coupon' => $code, + 'data' => [ + 'fixed_value' => true, + 'fixed_values' => [ + $price->currency->code => $price->decimal, + ], + ], + 'starts_at' => Carbon::now(), + ]); + + return $discount; + } +} diff --git a/src/Domain/Rewards/Actions/DepositPoints.php b/src/Domain/Rewards/Actions/DepositPoints.php new file mode 100644 index 0000000..40885d5 --- /dev/null +++ b/src/Domain/Rewards/Actions/DepositPoints.php @@ -0,0 +1,26 @@ +transactionCreator + ->amount($points->value) + ->currency($points->currency->code) + ->processor('deposit') + ->to($to) + ->overcharge() + ->commit(); + + return $transaction; + } +} diff --git a/src/Domain/Rewards/Actions/PointsAction.php b/src/Domain/Rewards/Actions/PointsAction.php new file mode 100644 index 0000000..518eaf9 --- /dev/null +++ b/src/Domain/Rewards/Actions/PointsAction.php @@ -0,0 +1,18 @@ +transactionCreator = App::make(TransactionCreator::class); + } +} diff --git a/src/Domain/Rewards/Actions/TransferPoints.php b/src/Domain/Rewards/Actions/TransferPoints.php new file mode 100644 index 0000000..489783b --- /dev/null +++ b/src/Domain/Rewards/Actions/TransferPoints.php @@ -0,0 +1,26 @@ +transactionCreator + ->amount($points->value) + ->currency($points->currency->code) + ->processor('transfer') + ->from($from) + ->to($to) + ->commit(); + + return $transaction; + } +} diff --git a/src/Domain/Rewards/Calculators/RewardPointsCalculator.php b/src/Domain/Rewards/Calculators/RewardPointsCalculator.php index b7449c5..73c556b 100644 --- a/src/Domain/Rewards/Calculators/RewardPointsCalculator.php +++ b/src/Domain/Rewards/Calculators/RewardPointsCalculator.php @@ -3,13 +3,14 @@ namespace Dystcz\LunarRewards\Domain\Rewards\Calculators; use Dystcz\LunarRewards\Domain\Rewards\Contracts\RewardPointsCalculator as RewardPointsCalculatorContract; +use Dystcz\LunarRewards\Domain\Rewards\DataTypes\Reward; use Illuminate\Support\Facades\Config; use Lunar\Models\Cart; use Lunar\Models\Order; class RewardPointsCalculator implements RewardPointsCalculatorContract { - protected int|float $coefficient; + protected float $rewardCoefficient; protected Order|Cart $model; @@ -20,13 +21,13 @@ public function __construct(Order|Cart $model) { $this->model = $model; - $this->setCoefficient(Config::get('lunar-rewards.reward_point_coefficient', 1)); + $this->setRewardCoefficient(Config::get('lunar-rewards.rewards.reward_coefficient', 1)); } /** * {@inheritDoc} */ - public function calculate(): int + public function calculate(): Reward { if (! $this->getModel()) { throw new \Exception('No model set for calculating reward points.'); @@ -36,11 +37,11 @@ public function calculate(): int $exchangeRate = $currency->exchange_rate; $subTotal = $this->getModel()->sub_total ?? $this->getModel()->subTotal ?? null; $subTotalValue = $subTotal ? $subTotal->value : 0; - $coefficient = $this->getCoefficient(); + $rewardCoefficient = $this->getRewardCoefficient(); - $points = (int) round($subTotalValue * $exchangeRate * $coefficient / 100); + $points = (int) round($subTotalValue * $exchangeRate * $rewardCoefficient / 100); - return $points; + return new Reward($points); } /** @@ -62,9 +63,9 @@ public function getModel(): Order|Cart|null /** * Set the coefficient for calculating reward points. */ - public function setCoefficient(int|float $coefficient): self + public function setRewardCoefficient(int|float $rewardCoefficient): self { - $this->coefficient = (float) $coefficient; + $this->rewardCoefficient = (float) $rewardCoefficient; return $this; } @@ -72,8 +73,8 @@ public function setCoefficient(int|float $coefficient): self /** * Get the coefficient used for calculating reward points. */ - public function getCoefficient(): float + public function getRewardCoefficient(): float { - return $this->coefficient; + return $this->rewardCoefficient; } } diff --git a/src/Domain/Rewards/Calculators/RewardValueCalculator.php b/src/Domain/Rewards/Calculators/RewardValueCalculator.php new file mode 100644 index 0000000..229befd --- /dev/null +++ b/src/Domain/Rewards/Calculators/RewardValueCalculator.php @@ -0,0 +1,115 @@ +reward = $reward; + + $this->currency = $currency; + + $this->setRewardCoefficient(Config::get('lunar-rewards.rewards.reward_coefficient', 1)); + $this->setValueCoefficient(Config::get('lunar-rewards.rewards.value_coefficient', 1)); + } + + /** + * {@inheritDoc} + */ + public function calculate(): Price + { + if (! $this->getReward()) { + throw new \Exception('No reward set for calculating the value.'); + } + + if (! $this->getCurrency()) { + throw new \Exception('No currency set for calculating the value.'); + } + + $rewardCoefficient = $this->getRewardCoefficient(); + $valueCoefficient = $this->getValueCoefficient(); + $exchangeRate = $this->getCurrency()->exchange_rate; + + $value = (int) round($this->getReward()->value * $exchangeRate / ($rewardCoefficient * $valueCoefficient) * 100); + + return new Price($value, $this->getCurrency()); + } + + /** + * Static constructor. + */ + public static function for(Reward $reward, Currency $currency): self + { + return new static($reward, $currency); + } + + /** + * Get the reward. + */ + public function getReward(): Reward + { + return $this->reward; + } + + /** + * Get the currency. + */ + public function getCurrency(): Currency + { + return $this->currency; + } + + /** + * Set the value coefficient. + */ + public function setValueCoefficient(int|float $valueCoefficient): self + { + $this->valueCoefficient = (float) $valueCoefficient; + + return $this; + } + + /** + * Get the value coefficient. + */ + public function getValueCoefficient(): float + { + return $this->valueCoefficient; + } + + /** + * Set the reward points coefficient. + */ + public function setRewardCoefficient(int|float $rewardCoefficient): self + { + $this->rewardCoefficient = (float) $rewardCoefficient; + + return $this; + } + + /** + * Get the reward points coefficient. + */ + public function getRewardCoefficient(): float + { + return $this->rewardCoefficient; + } +} diff --git a/src/Domain/Rewards/Contracts/RewardPointsCalculator.php b/src/Domain/Rewards/Contracts/RewardPointsCalculator.php index fcb111d..b564381 100644 --- a/src/Domain/Rewards/Contracts/RewardPointsCalculator.php +++ b/src/Domain/Rewards/Contracts/RewardPointsCalculator.php @@ -2,6 +2,7 @@ namespace Dystcz\LunarRewards\Domain\Rewards\Contracts; +use Dystcz\LunarRewards\Domain\Rewards\DataTypes\Reward; use Lunar\Models\Cart; use Lunar\Models\Order; @@ -15,5 +16,5 @@ public function __construct(Order|Cart $model); /** * Calculate the reward points for the given order. */ - public function calculate(): int; + public function calculate(): Reward; } diff --git a/src/Domain/Rewards/Contracts/RewardValueCalculator.php b/src/Domain/Rewards/Contracts/RewardValueCalculator.php new file mode 100644 index 0000000..e80aaa7 --- /dev/null +++ b/src/Domain/Rewards/Contracts/RewardValueCalculator.php @@ -0,0 +1,20 @@ +currency = App::make('lunar-rewards-currency'); + + parent::__construct($value, $this->currency, $unitQty); + } +} diff --git a/src/Domain/Rewards/Managers/PointBalanceManager.php b/src/Domain/Rewards/Managers/PointBalanceManager.php new file mode 100644 index 0000000..8ea5407 --- /dev/null +++ b/src/Domain/Rewards/Managers/PointBalanceManager.php @@ -0,0 +1,198 @@ +balance = $model->balance(); + } + + /** + * Create a new instance of the action. + */ + public static function of(Rewardable $model): self + { + return new static($model); + } + + /** + * Get the model. + */ + public function model(): Rewardable + { + return $this->model; + } + + /** + * Get balance model. + */ + public function balance(): Balance + { + return $this->balance; + } + + /** + * Get transactions query. + */ + public function getTransactionsQuery(): Builder + { + return $this + ->balance() + ->transactions(); + } + + /** + * Get transactions. + * + * @return Collection + */ + public function getTransactions(): Collection + { + return $this + ->getTransactionsQuery() + ->get(); + } + + /** + * Determine if the model has enough points. + */ + public function hasEnoughPoints(Reward $points): bool + { + return $this + ->model() + ->isEnoughFunds((string) $points, $points->currency->code); + } + + /** + * Get balance. + */ + public function get(): Numeric + { + return $this->balance->value; + } + + /** + * Get balance reward. + */ + public function getReward(): Reward + { + return new Reward((int) $this->get()->get()); + } + + /** + * Get balance value. + */ + public function getValue(): int + { + return $this->getReward()->value; + } + + /** + * Get pending. + */ + public function getPending(): Numeric + { + return new Numeric($this->balance->value_pending); + } + + /** + * Get pending reward. + */ + public function getPendingReward(): Reward + { + return new Reward((int) $this->getPending()->get()); + } + + /** + * Get pending value. + */ + public function getPendingValue(): int + { + return $this->getPendingReward()->value; + } + + /** + * Get on hold value. + */ + public function getOnHold(): Numeric + { + return new Numeric($this->balance->value_on_hold); + } + + /** + * Get on hold reward. + */ + public function getOnHoldReward(): Reward + { + return new Reward((int) $this->getOnHold()->get()); + } + + /** + * Get on hold value. + */ + public function getOnHoldValue(): int + { + return $this->getOnHoldReward()->value; + } + + /** + * Get sent. + */ + public function getSent(): Numeric + { + return $this->balance->sent; + } + + /** + * Get sent reward. + */ + public function getSentReward(): Reward + { + return new Reward((int) $this->getSent()->get()); + } + + /** + * Get sent value. + */ + public function getSentValue(): int + { + return $this->getSentReward()->value; + } + + /** + * Get received. + */ + public function getReceived(): Numeric + { + return $this->balance->received; + } + + /** + * Get received reward. + */ + public function getReceivedReward(): Reward + { + return new Reward((int) $this->getReceived()->get()); + } + + /** + * Get received value. + */ + public function getReceivedValue(): int + { + return $this->getReceivedReward()->value; + } +} diff --git a/src/Domain/Rewards/Models/Balance.php b/src/Domain/Rewards/Models/Balance.php new file mode 100644 index 0000000..fe43186 --- /dev/null +++ b/src/Domain/Rewards/Models/Balance.php @@ -0,0 +1,19 @@ +setTable("{$tablePrefix}{$tableName}"); + } +} diff --git a/src/Domain/Rewards/Models/BalanceState.php b/src/Domain/Rewards/Models/BalanceState.php new file mode 100644 index 0000000..cc6c2b1 --- /dev/null +++ b/src/Domain/Rewards/Models/BalanceState.php @@ -0,0 +1,19 @@ +setTable("{$tablePrefix}{$tableName}"); + } +} diff --git a/src/Domain/Rewards/Models/Transaction.php b/src/Domain/Rewards/Models/Transaction.php new file mode 100644 index 0000000..d86f0f0 --- /dev/null +++ b/src/Domain/Rewards/Models/Transaction.php @@ -0,0 +1,19 @@ +setTable("{$tablePrefix}{$tableName}"); + } +} diff --git a/src/Domain/Rewards/Traits/HasRewardPointsBalance.php b/src/Domain/Rewards/Traits/HasRewardPointsBalance.php new file mode 100644 index 0000000..d2d04d4 --- /dev/null +++ b/src/Domain/Rewards/Traits/HasRewardPointsBalance.php @@ -0,0 +1,10 @@ +deposit = App::make(DepositPoints::class); + $this->charge = App::make(ChargePoints::class); + $this->transfer = App::make(TransferPoints::class); + } + + /** + * Deposit points to the model. + */ + public function deposit(Rewardable $to, Reward $points): Transaction + { + return $this->deposit->handle($to, $points); + } + + /** + * Charge points from the model. + */ + public function charge(Rewardable $from, Reward $points): Transaction + { + return $this->charge->handle($from, $points); + } + + /** + * Transfer points from one model to another. + */ + public function transfer(Rewardable $from, Rewardable $to, Reward $points): Transaction + { + return $this->transfer->handle($from, $to, $points); + } + + /** + * Get list of transactions. + * + * @return Collection + */ + public function transactions(Rewardable $model): Collection + { + return $this->balanceManager($model)->getTransactions(); + } + + /** + * Get the balance of the model. + */ + public function balance(Rewardable $model): int + { + return $this->balanceManager($model)->getValue(); + } + + /** + * Get the balance manager of the model. + */ + public function balanceManager(Rewardable $model): PointBalanceManager + { + return PointBalanceManager::of($model); + } } diff --git a/src/LunarRewardsServiceProvider.php b/src/LunarRewardsServiceProvider.php index 1234566..b8d52aa 100644 --- a/src/LunarRewardsServiceProvider.php +++ b/src/LunarRewardsServiceProvider.php @@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; +use Lunar\Models\Currency; class LunarRewardsServiceProvider extends ServiceProvider { @@ -37,13 +38,36 @@ public function register(): void fn () => new LunarRewards, ); + // Register the reward points currency. + $this->app->singleton( + 'lunar-rewards-currency', + fn () => new Currency([ + 'code' => Config::get('wallet.default_currency', 'RP'), + 'name' => 'Reward Points', + 'exchange_rate' => 1, + 'decimal_places' => 0, + 'enabled' => true, + ]), + ); + + // Register the reward points calculator. $this->app->singleton( \Dystcz\LunarRewards\Domain\Rewards\Contracts\RewardPointsCalculator::class, fn () => new (Config::get( - 'lunar-rewards.reward_point_calculator', + 'lunar-rewards.rewards.reward_point_calculator', \Dystcz\LunarRewards\Domain\Rewards\Calculators\RewardPointsCalculator::class, )), ); + + // Register coupon code generator. + $this->app->singleton( + \Dystcz\LunarRewards\Domain\Discounts\Contracts\CouponCodeGenerator::class, + fn () => new (Config::get( + 'lunar-rewards.rewards.coupon_code_generator', + \Dystcz\LunarRewards\Domain\Discounts\Generators\CouponCodeGenerator::class, + )), + ); + } /** @@ -74,8 +98,12 @@ protected function publishConfig(): void foreach ($this->configFiles as $configFile) { $this->publishes([ "{$this->root}/config/{$configFile}.php" => config_path("lunar-rewards/{$configFile}.php"), - ], 'lunar-rewards'); + ], 'lunar-rewards.config'); } + + $this->publishes([ + "{$this->root}/config/wallet.php" => config_path('wallet.php'), + ], 'lunar-rewards.config'); } /** @@ -99,6 +127,11 @@ protected function registerConfig(): void "lunar-rewards.{$configFile}", ); } + + $this->mergeConfigFrom( + "{$this->root}/config/wallet.php", + 'wallet', + ); } /** diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php new file mode 100644 index 0000000..1e73b07 --- /dev/null +++ b/tests/Feature/ExampleTest.php @@ -0,0 +1,10 @@ +assertTrue(true); +}); diff --git a/tests/Stubs/Carts/Cart.php b/tests/Stubs/Carts/Cart.php new file mode 100644 index 0000000..2382e0a --- /dev/null +++ b/tests/Stubs/Carts/Cart.php @@ -0,0 +1,12 @@ +setUpDatabase(); + Config::set('auth.providers.users', [ 'driver' => 'eloquent', 'model' => User::class, @@ -35,9 +36,7 @@ protected function setUp(): void Config::set('lunar.urls.generator', TestUrlGenerator::class); Config::set('lunar.taxes.driver', 'test'); - Taxes::extend('test', function ($app) { - return $app->make(TestTaxDriver::class); - }); + Taxes::extend('test', fn (Application $app) => $app->make(TestTaxDriver::class)); activity()->disableLogging(); } @@ -78,6 +77,9 @@ protected function getPackageProviders($app): array // Lunar Hub \Lunar\Hub\AdminHubServiceProvider::class, + // Laravel Wallet + \O21\LaravelWallet\ServiceProvider::class, + // Lunar Rewards \Dystcz\LunarRewards\LunarRewardsServiceProvider::class, ]; @@ -108,6 +110,16 @@ public function getEnvironmentSetUp($app): void 'prefix' => '', ]); + /** + * Wallet configuration. + */ + Config::set('wallet.default_currency', 'RP'); + Config::set('wallet.table_names', [ + 'balances' => 'lunar_rewards_balances', + 'balance_states' => 'lunar_rewards_balance_states', + 'transactions' => 'lunar_rewards_transactions', + ]); + Config::set('database.connections.mysql', [ 'driver' => 'mysql', 'host' => 'mysql', @@ -129,17 +141,24 @@ public function getEnvironmentSetUp($app): void protected function defineDatabaseMigrations(): void { $this->loadLaravelMigrations(); + } + + /** + * Set up the database. + */ + protected function setUpDatabase(): void + { + $walletMigrations = [ + 'database/migrations/create_balances_table.php.stub', + 'database/migrations/create_transactions_table.php.stub', + 'database/migrations/create_balance_states_table.php.stub', + ]; + + foreach ($walletMigrations as $migration) { + $migration = include __DIR__."/../vendor/021/laravel-wallet/{$migration}"; - // NOTE MySQL migrations do not play nice with Lunar testing for some reason - // // artisan($this, 'lunar:install'); - // // artisan($this, 'vendor:publish', ['--tag' => 'lunar']); - // // artisan($this, 'vendor:publish', ['--tag' => 'lunar.migrations']); - // - // // artisan($this, 'migrate', ['--database' => 'mysql']); - // - // $this->beforeApplicationDestroyed( - // fn () => artisan($this, 'migrate:rollback', ['--database' => 'mysql']) - // ); + $migration->up(); + } } /** diff --git a/tests/Traits/CreatesTestingModels.php b/tests/Traits/CreatesTestingModels.php index d4636ab..96a79c7 100644 --- a/tests/Traits/CreatesTestingModels.php +++ b/tests/Traits/CreatesTestingModels.php @@ -3,6 +3,7 @@ namespace Dystcz\LunarRewards\Tests\Traits; use Dystcz\LunarApi\Domain\Carts\Models\Cart; +use Dystcz\LunarRewards\Tests\Stubs\Users\User; use Illuminate\Database\Eloquent\Model; use Lunar\DataTypes\Price as PriceDataType; use Lunar\DataTypes\ShippingOption; @@ -19,6 +20,14 @@ trait CreatesTestingModels { + /** + * @param array $models + */ + public function createUser(...$models): User + { + return User::factory()->create(); + } + /** * @param array $models */ diff --git a/tests/Unit/Domain/Coupons/Generators/CouponCodeGeneratorTest.php b/tests/Unit/Domain/Coupons/Generators/CouponCodeGeneratorTest.php new file mode 100644 index 0000000..35dd698 --- /dev/null +++ b/tests/Unit/Domain/Coupons/Generators/CouponCodeGeneratorTest.php @@ -0,0 +1,63 @@ +generate(); + + $this->assertIsString($code); + $this->assertEquals(CouponCodeGenerator::LENGTH, strlen($code)); + +})->group('rewards', 'discounts', 'generators'); + +it('can recursively generate unique coupon code', function () { + /** @var TestCase $this */ + App::singleton(CouponCodeGeneratorContract::class, fn () => new TestCouponCodeGenerator); + + /** @var CouponCodeGenerator $generator */ + $generator = App::make(CouponCodeGeneratorContract::class); + + Discount::factory()->create(['coupon' => '10']); + Discount::factory()->create(['coupon' => '11']); + Discount::factory()->create(['coupon' => '13']); + Discount::factory()->create(['coupon' => '14']); + Discount::factory()->create(['coupon' => '15']); + Discount::factory()->create(['coupon' => '17']); + Discount::factory()->create(['coupon' => '18']); + Discount::factory()->create(['coupon' => '19']); + Discount::factory()->create(['coupon' => '20']); + + $ranTimes = 0; + $code = null; + + while (true) { + $ranTimes = Cache::get('coupon-generator-test'); + if ($ranTimes > 1) { + break; + } else { + Cache::forget('coupon-generator-test'); + } + + $code = $generator->generate(); + } + + $this->assertGreaterThan(1, $ranTimes); + $this->assertTrue(in_array($code, ['12', '16'])); + $this->assertIsString($code); + $this->assertEquals(TestCouponCodeGenerator::LENGTH, strlen($code)); + +})->group('rewards', 'discounts', 'generators'); diff --git a/tests/Unit/Domain/Rewards/Actions/ChargePointsTest.php b/tests/Unit/Domain/Rewards/Actions/ChargePointsTest.php new file mode 100644 index 0000000..12bad05 --- /dev/null +++ b/tests/Unit/Domain/Rewards/Actions/ChargePointsTest.php @@ -0,0 +1,42 @@ +createUser(); + + $balance = PointBalanceManager::of($user); + + (new DepositPoints)->handle(to: $user, points: new Reward(1000)); + (new ChargePoints)->handle(from: $user, points: new Reward(200)); + (new ChargePoints)->handle(from: $user, points: new Reward(300)); + (new ChargePoints)->handle(from: $user, points: new Reward(50)); + + $this->assertEquals(450, $balance->getValue()); +})->group('rewards', 'balance', 'charge'); + +it('it throws an exception if there are not enough funds to charge', function () { + + /** @var TestCase $this */ + $user = $this->createUser(); + + $balance = PointBalanceManager::of($user); + + (new DepositPoints)->handle(to: $user, points: new Reward(1000)); + + $this->assertThrows( + fn () => (new ChargePoints)->handle(from: $user, points: new Reward(1200)), + InsufficientFundsException::class, + ); + +})->group('rewards', 'balance', 'charge'); diff --git a/tests/Unit/Domain/Rewards/Actions/CreateCouponFromBalanceTest.php b/tests/Unit/Domain/Rewards/Actions/CreateCouponFromBalanceTest.php new file mode 100644 index 0000000..47ef447 --- /dev/null +++ b/tests/Unit/Domain/Rewards/Actions/CreateCouponFromBalanceTest.php @@ -0,0 +1,37 @@ +create([ + 'decimal_places' => 2, + 'exchange_rate' => 1.0, + ]); + + $user = $this->createUser(); + + $balance = PointBalanceManager::of($user); + + (new DepositPoints)->handle(to: $user, points: new Reward(1000)); + + $coupon = App::make(CreateCouponFromBalance::class)->handle($user, $currency); + + $this->assertEquals($coupon->data, [ + 'fixed_value' => true, + 'fixed_values' => [ + $currency->code => 10, + ], + ]); + +})->group('rewards', 'coupons'); diff --git a/tests/Unit/Domain/Rewards/Actions/DepositPointsTest.php b/tests/Unit/Domain/Rewards/Actions/DepositPointsTest.php new file mode 100644 index 0000000..6f4270f --- /dev/null +++ b/tests/Unit/Domain/Rewards/Actions/DepositPointsTest.php @@ -0,0 +1,21 @@ +createUser(); + + $balance = PointBalanceManager::of($user); + + (new DepositPoints)->handle(to: $user, points: new Reward(100)); + + $this->assertEquals(100, $balance->getValue()); +})->group('rewards', 'balance', 'deposit'); diff --git a/tests/Unit/Domain/Rewards/Actions/TransferPointsTest.php b/tests/Unit/Domain/Rewards/Actions/TransferPointsTest.php new file mode 100644 index 0000000..d0937d9 --- /dev/null +++ b/tests/Unit/Domain/Rewards/Actions/TransferPointsTest.php @@ -0,0 +1,56 @@ +createUser(); + $user2 = $this->createUser(); + + (new DepositPoints)->handle(to: $user, points: new Reward(1000)); + (new DepositPoints)->handle(to: $user2, points: new Reward(200)); + + $balance = PointBalanceManager::of($user); + $balance2 = PointBalanceManager::of($user2); + + (new TransferPoints)->handle(from: $user, to: $user2, points: new Reward(200)); // 800, 400 + (new TransferPoints)->handle(from: $user2, to: $user, points: new Reward(100)); // 900, 300 + (new TransferPoints)->handle(from: $user, to: $user2, points: new Reward(150)); // 750, 450 + (new TransferPoints)->handle(from: $user, to: $user2, points: new Reward(50)); // 700, 500 + (new TransferPoints)->handle(from: $user2, to: $user, points: new Reward(20)); // 720, 480 + + // User + $this->assertEquals(720, $balance->getValue()); + $this->assertEquals(400, $balance->getSentValue()); + $this->assertEquals(1000 + 120, $balance->getReceivedValue()); + + // User 2 + $this->assertEquals(480, $balance2->getValue()); + $this->assertEquals(120, $balance2->getSentValue()); + $this->assertEquals(200 + 400, $balance2->getReceivedValue()); + +})->group('rewards', 'balance', 'transfer'); + +it('it throws an exception if there are not enough funds to charge', function () { + + /** @var TestCase $this */ + $user = $this->createUser(); + $user2 = $this->createUser(); + + (new DepositPoints)->handle(to: $user, points: new Reward(1000)); + + $this->assertThrows( + fn () => (new TransferPoints)->handle(from: $user, to: $user2, points: new Reward(1200)), + InsufficientFundsException::class, + ); + +})->group('rewards', 'balance', 'charge'); diff --git a/tests/Unit/Domain/Rewards/Actions/RewardPointsCalculatorTest.php b/tests/Unit/Domain/Rewards/Calculators/RewardPointsCalculatorTest.php similarity index 77% rename from tests/Unit/Domain/Rewards/Actions/RewardPointsCalculatorTest.php rename to tests/Unit/Domain/Rewards/Calculators/RewardPointsCalculatorTest.php index debfdb2..87a1289 100644 --- a/tests/Unit/Domain/Rewards/Actions/RewardPointsCalculatorTest.php +++ b/tests/Unit/Domain/Rewards/Calculators/RewardPointsCalculatorTest.php @@ -8,7 +8,7 @@ uses(TestCase::class, RefreshDatabase::class); -it('can calculate points from order correctly', function () { +it('can calculate points from order', function () { /** @var TestCase $this */ $currency = Currency::factory()->create([ @@ -21,11 +21,11 @@ currency: $currency, ); - $points = RewardPointsCalculator::for($order) - ->setCoefficient(2) + $reward = RewardPointsCalculator::for($order) + ->setRewardCoefficient(2) ->calculate(); - $this->assertEquals(20, $points); + $this->assertEquals(20, $reward->value); })->group('rewards', 'point-calculator'); @@ -42,11 +42,11 @@ currency: $currency, ); - $points = RewardPointsCalculator::for($order) - ->setCoefficient(10) + $reward = RewardPointsCalculator::for($order) + ->setRewardCoefficient(10) ->calculate(); - $this->assertEquals(150, $points); + $this->assertEquals(150, $reward->value); })->group('rewards', 'point-calculator'); @@ -65,10 +65,10 @@ currency: $currency, ); - $points = RewardPointsCalculator::for($order) + $reward = RewardPointsCalculator::for($order) ->calculate(); - $this->assertEquals(100, $points); + $this->assertEquals(100, $reward->value); })->group('rewards', 'point-calculator'); @@ -85,10 +85,10 @@ currency: $currency, ); - $points = RewardPointsCalculator::for($cart) - ->setCoefficient(3) + $reward = RewardPointsCalculator::for($cart) + ->setRewardCoefficient(3) ->calculate(); - $this->assertEquals(60, $points); + $this->assertEquals(60, $reward->value); })->group('rewards', 'point-calculator'); diff --git a/tests/Unit/Domain/Rewards/Calculators/RewardValueCalculatorTest.php b/tests/Unit/Domain/Rewards/Calculators/RewardValueCalculatorTest.php new file mode 100644 index 0000000..ffd489f --- /dev/null +++ b/tests/Unit/Domain/Rewards/Calculators/RewardValueCalculatorTest.php @@ -0,0 +1,24 @@ +create([ + 'decimal_places' => 2, + 'exchange_rate' => 1.0, + ]); + + $calculator = RewardValueCalculator::for($reward, $currency); + + $this->assertEquals(1000, $calculator->calculate()->value); + +})->group('rewards', 'value-calculator'); diff --git a/tests/Unit/Domain/Rewards/Managers/PointBalanceManagerTest.php b/tests/Unit/Domain/Rewards/Managers/PointBalanceManagerTest.php new file mode 100644 index 0000000..b5bc2ee --- /dev/null +++ b/tests/Unit/Domain/Rewards/Managers/PointBalanceManagerTest.php @@ -0,0 +1,135 @@ +createUser(); + $user2 = $this->createUser(); + + $balance = PointBalanceManager::of($user); + $balance2 = PointBalanceManager::of($user2); + + $this->assertEquals(0, $balance->getValue()); + $this->assertEquals(0, $balance2->getValue()); + + (new DepositPoints)->handle(to: $user, points: new Reward(1000)); + $this->assertEquals(1000, $balance->getValue()); + $this->assertEquals(1000, $balance->getReceivedValue()); + $this->assertEquals(0, $balance->getSentValue()); + + (new ChargePoints)->handle(from: $user, points: new Reward(500)); + $this->assertEquals(500, $balance->getValue()); + $this->assertEquals(1000, $balance->getReceivedValue()); + $this->assertEquals(500, $balance->getSentValue()); + + (new DepositPoints)->handle(to: $user, points: new Reward(200)); + $this->assertEquals(700, $balance->getValue()); + $this->assertEquals(1200, $balance->getReceivedValue()); + $this->assertEquals(500, $balance->getSentValue()); + + (new TransferPoints)->handle(from: $user, to: $user2, points: new Reward(450)); + $this->assertEquals(250, $balance->getValue()); + $this->assertEquals(1200, $balance->getReceivedValue()); + $this->assertEquals(950, $balance->getSentValue()); + $this->assertEquals(450, $balance2->getValue()); + $this->assertEquals(450, $balance2->getReceivedValue()); + $this->assertEquals(0, $balance2->getSentValue()); + +})->group('rewards', 'balance', 'managers', 'balance-manager'); + +it('can get correct values in Numeric, Reward and int', function () { + + /** @var TestCase $this */ + $user = $this->createUser(); + $user2 = $this->createUser(); + + $balance = PointBalanceManager::of($user); + $balance2 = PointBalanceManager::of($user2); + + (new DepositPoints)->handle(to: $user, points: new Reward(1000)); + (new ChargePoints)->handle(from: $user, points: new Reward(500)); + (new TransferPoints)->handle(from: $user, to: $user2, points: new Reward(450)); + + // Balance + $this->assertEquals(new Numeric(50), $balance->get()); + $this->assertEquals(new Reward(50), $balance->getReward()); + $this->assertEquals(50, $balance->getValue()); + + // Pending + $this->assertEquals(new Numeric(0), $balance->getPending()); + $this->assertEquals(new Reward(0), $balance->getPendingReward()); + $this->assertEquals(0, $balance->getPendingValue()); + + // On hold + $this->assertEquals(new Numeric(0), $balance->getOnHold()); + $this->assertEquals(new Reward(0), $balance->getOnHoldReward()); + $this->assertEquals(0, $balance->getOnHoldValue()); + + // Sent + $this->assertEquals(new Numeric(950), $balance->getSent()); + $this->assertEquals(new Reward(950), $balance->getSentReward()); + $this->assertEquals(950, $balance->getSentValue()); + + // Received + $this->assertEquals(new Numeric(1000), $balance->getReceived()); + $this->assertEquals(new Reward(1000), $balance->getReceivedReward()); + $this->assertEquals(1000, $balance->getReceivedValue()); + +})->group('rewards', 'balance', 'managers', 'balance-manager'); + +it('can get model transactions', function () { + + /** @var TestCase $this */ + $user = $this->createUser(); + $user2 = $this->createUser(); + + $balance = PointBalanceManager::of($user); + + (new DepositPoints)->handle(to: $user, points: new Reward(1000)); + (new ChargePoints)->handle(from: $user, points: new Reward(500)); + (new TransferPoints)->handle(from: $user, to: $user2, points: new Reward(450)); + + $transactions = $balance->getTransactions(); + + $this->assertInstanceOf(Collection::class, $transactions); + $this->assertEquals(3, $transactions->count()); + + $this->assertInstanceOf(Builder::class, $balance->getTransactionsQuery()); + + $transactions = $balance->getTransactionsQuery()->limit(1)->get(); + + $this->assertInstanceOf(Collection::class, $transactions); + $this->assertEquals(1, $transactions->count()); + +})->group('rewards', 'balance', 'managers', 'balance-manager'); + +it('can check if model has enough points to perform an operation', function () { + + /** @var TestCase $this */ + $user = $this->createUser(); + + $balance = PointBalanceManager::of($user); + + (new DepositPoints)->handle(to: $user, points: new Reward(200)); + + $this->assertEquals(200, $balance->getValue()); + + $this->assertTrue($balance->hasEnoughPoints(new Reward(100))); + $this->assertFalse($balance->hasEnoughPoints(new Reward(300))); + +})->group('rewards', 'balance', 'managers', 'balance-manager'); diff --git a/tests/Unit/Facades/LunarRewardsTest.php b/tests/Unit/Facades/LunarRewardsTest.php new file mode 100644 index 0000000..06227ba --- /dev/null +++ b/tests/Unit/Facades/LunarRewardsTest.php @@ -0,0 +1,91 @@ +createUser(); + + (new DepositPoints)->handle($user, new Reward(1000)); + + $balance = LunarRewards::balanceManager($user); + + $this->assertEquals(1000, $balance->getValue()); + $this->assertEquals(1000, LunarRewards::balance($user)); + +})->group('rewards', 'facade'); + +it('can deposit points by calling a facade', function () { + + /** @var TestCase $this */ + $user = $this->createUser(); + + $balance = LunarRewards::balanceManager($user); + + LunarRewards::deposit($user, new Reward(1000)); + + $this->assertEquals(1000, $balance->getValue()); + $this->assertEquals(1000, LunarRewards::balance($user)); + +})->group('rewards', 'facade'); + +it('can charge points by calling a facade', function () { + + /** @var TestCase $this */ + $user = $this->createUser(); + + LunarRewards::deposit($user, new Reward(500)); + + $balance = LunarRewards::balanceManager($user); + + LunarRewards::charge($user, new Reward(200)); + + $this->assertEquals(300, $balance->getValue()); + $this->assertEquals(300, LunarRewards::balance($user)); + +})->group('rewards', 'facade'); + +it('can transfer points by calling a facade', function () { + + /** @var TestCase $this */ + $user = $this->createUser(); + $user2 = $this->createUser(); + + LunarRewards::deposit($user, new Reward(600)); + + $balance = LunarRewards::balanceManager($user); + $balance2 = LunarRewards::balanceManager($user2); + + LunarRewards::transfer($user, $user2, new Reward(400)); + + $this->assertEquals(200, $balance->getValue()); + $this->assertEquals(200, LunarRewards::balance($user)); + + $this->assertEquals(400, $balance2->getValue()); + $this->assertEquals(400, LunarRewards::balance($user2)); + +})->group('rewards', 'facade'); + +it('can get list of transactions by calling a facade', function () { + + /** @var TestCase $this */ + $user = $this->createUser(); + $user2 = $this->createUser(); + + LunarRewards::deposit($user, new Reward(600)); + LunarRewards::charge($user, new Reward(200)); + LunarRewards::transfer($user, $user2, new Reward(100)); + + $this->assertCount(3, LunarRewards::transactions($user)); + $this->assertCount(1, LunarRewards::transactions($user2)); + $this->assertInstanceOf(Collection::class, LunarRewards::transactions($user)); + +})->group('rewards', 'facade');