From ea37be180aaaadefc14adb51fb7bba4c329efb72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20S=2E=20Campos?= Date: Sun, 17 Dec 2023 21:12:52 -0500 Subject: [PATCH] feature: add gift domain --- README.md | 1 + composer.json | 1 + composer.lock | 90 ++++++++++++++++++- database/factories/DBGiftFactory.php | 20 +++++ .../2023_12_18_013819_create_gifts_table.php | 32 +++++++ src/Domain/Gifts/Actions/StoreGiftAction.php | 45 ++++++++++ src/Domain/Gifts/Data/StoreGiftData.php | 26 ++++++ src/Domain/Gifts/Models/Gift.php | 57 ++++++++++++ .../Gifts/QueryBuilders/GiftQueryBuilder.php | 30 +++++++ src/Domain/Users/Models/User.php | 16 +++- .../Feature/ApiAdmin/Users/IndexUserTest.php | 2 +- .../Gifts/Actions/StoreGiftActionTest.php | 67 ++++++++++++++ tests/Unit/Gifts/Models/GiftTest.php | 22 +++++ tests/Unit/Users/Models/UserTest.php | 12 +++ 14 files changed, 417 insertions(+), 4 deletions(-) create mode 100644 database/factories/DBGiftFactory.php create mode 100644 database/migrations/2023_12_18_013819_create_gifts_table.php create mode 100644 src/Domain/Gifts/Actions/StoreGiftAction.php create mode 100644 src/Domain/Gifts/Data/StoreGiftData.php create mode 100644 src/Domain/Gifts/Models/Gift.php create mode 100644 src/Domain/Gifts/QueryBuilders/GiftQueryBuilder.php create mode 100644 tests/Unit/Gifts/Actions/StoreGiftActionTest.php create mode 100644 tests/Unit/Gifts/Models/GiftTest.php diff --git a/README.md b/README.md index 6f76e4f..a2ec4b2 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,7 @@ sail artisan scribe:generate - [Queueable actions in Laravel](https://github.com/spatie/laravel-queueable-action) - [Laravel Model State](https://spatie.be/docs/laravel-model-states/v2/01-introduction) - Advanced state support for Laravel models - [PEST PHP](https://pestphp.com/) - An elegant PHP Testing Framework +- [MoneyPHP](https://moneyphp.org/) - PHP implementation of Fowler's Money pattern. - [Scribe](https://scribe.knuckles.wtf/laravel/) - Generate API documentation for humans from your Laravel codebase. ### Authors diff --git a/composer.json b/composer.json index 22c0662..338d131 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,7 @@ "laravel/vapor-core": "^2.33", "league/flysystem-aws-s3-v3": "3.0", "maatwebsite/excel": "^3.1", + "moneyphp/money": "^4.3", "rebing/graphql-laravel": "^9.1", "spatie/laravel-activitylog": "^4.7", "spatie/laravel-data": "^2.1", diff --git a/composer.lock b/composer.lock index 814ee33..ffb3c66 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b3b8da7c80bdd5b92708b95e8fba149e", + "content-hash": "b5d1823225f5439d27c837a774175668", "packages": [ { "name": "aws/aws-crt-php", @@ -3575,6 +3575,94 @@ }, "time": "2022-12-02T22:17:43+00:00" }, + { + "name": "moneyphp/money", + "version": "v4.3.0", + "source": { + "type": "git", + "url": "https://github.com/moneyphp/money.git", + "reference": "50ddfd15b2be01d4bed3bcb0c975a6af5f78a183" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/moneyphp/money/zipball/50ddfd15b2be01d4bed3bcb0c975a6af5f78a183", + "reference": "50ddfd15b2be01d4bed3bcb0c975a6af5f78a183", + "shasum": "" + }, + "require": { + "ext-bcmath": "*", + "ext-filter": "*", + "ext-json": "*", + "php": "~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0" + }, + "require-dev": { + "cache/taggable-cache": "^1.1.0", + "doctrine/coding-standard": "^9.0", + "doctrine/instantiator": "^1.4.0", + "ext-gmp": "*", + "ext-intl": "*", + "florianv/exchanger": "^2.6.3", + "florianv/swap": "^4.3.0", + "moneyphp/crypto-currencies": "^1.0.0", + "moneyphp/iso-currencies": "^3.2.1", + "php-http/message": "^1.11.0", + "php-http/mock-client": "^1.4.1", + "phpbench/phpbench": "^1.2.5", + "phpunit/phpunit": "^9.5.4", + "psalm/plugin-phpunit": "^0.18.4", + "psr/cache": "^1.0.1", + "vimeo/psalm": "~5.15.0" + }, + "suggest": { + "ext-gmp": "Calculate without integer limits", + "ext-intl": "Format Money objects with intl", + "florianv/exchanger": "Exchange rates library for PHP", + "florianv/swap": "Exchange rates library for PHP", + "psr/cache-implementation": "Used for Currency caching" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Money\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Verraes", + "email": "mathias@verraes.net", + "homepage": "http://verraes.net" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + }, + { + "name": "Frederik Bosch", + "email": "f.bosch@genkgo.nl" + } + ], + "description": "PHP implementation of Fowler's Money pattern", + "homepage": "http://moneyphp.org", + "keywords": [ + "Value Object", + "money", + "vo" + ], + "support": { + "issues": "https://github.com/moneyphp/money/issues", + "source": "https://github.com/moneyphp/money/tree/v4.3.0" + }, + "time": "2023-11-22T09:46:30+00:00" + }, { "name": "monolog/monolog", "version": "3.5.0", diff --git a/database/factories/DBGiftFactory.php b/database/factories/DBGiftFactory.php new file mode 100644 index 0000000..9f1fddf --- /dev/null +++ b/database/factories/DBGiftFactory.php @@ -0,0 +1,20 @@ + $this->faker->text, + 'amount' => $this->faker->numberBetween(1, 1000), + 'currency' => 'USD', + ]; + } +} diff --git a/database/migrations/2023_12_18_013819_create_gifts_table.php b/database/migrations/2023_12_18_013819_create_gifts_table.php new file mode 100644 index 0000000..b81846d --- /dev/null +++ b/database/migrations/2023_12_18_013819_create_gifts_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('note', 255)->nullable(); + $table->bigInteger('amount'); + $table->char('currency', 3); + $table->foreignId('user_id')->constrained('users'); + $table->foreignId('sender_user_id')->constrained('users'); + // TODO: Evaluate if add or not pocket_id field, it can be gotten from the user now + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('gifts'); + } +}; diff --git a/src/Domain/Gifts/Actions/StoreGiftAction.php b/src/Domain/Gifts/Actions/StoreGiftAction.php new file mode 100644 index 0000000..e65f791 --- /dev/null +++ b/src/Domain/Gifts/Actions/StoreGiftAction.php @@ -0,0 +1,45 @@ +whereId($user->pocket_id) + ->select(['id', 'balance', 'currency']) + ->first(); + + // TODO: Throw an error when there is no user pocket + + // TODO: Support conversion between currencies + if ($userPocket->currency !== $data->currency) { + // TODO: Create a custom exception to be handed in the controller + throw new \DomainException('User pocket currency does not match gift currency'); + } + + $userMoney = new Money($userPocket->balance, new Currency($userPocket->currency)); /* @phpstan-ignore-line */ + $giftMoney = new Money($data->amount, new Currency($data->currency)); /* @phpstan-ignore-line */ + + $userPocket->balance = (int) $userMoney->add($giftMoney)->getAmount(); + $userPocket->update(); + + $gift = new Gift(); + $gift->note = $data->note; + $gift->amount = $data->amount; + $gift->currency = $data->currency; + $gift->senderUser()->associate($senderUser); + $gift->user()->associate($user); + $gift->save(); + + return $gift; + } +} diff --git a/src/Domain/Gifts/Data/StoreGiftData.php b/src/Domain/Gifts/Data/StoreGiftData.php new file mode 100644 index 0000000..291e1ae --- /dev/null +++ b/src/Domain/Gifts/Data/StoreGiftData.php @@ -0,0 +1,26 @@ + ['required', 'string', 'min:3', 'max:255'], + // TODO: Add a logic about payment method, where the money is supposed to come from + 'amount' => ['required', 'integer', 'min:100'], + // TODO: Add validated currency rule + 'currency' => ['required', 'string'], + ]; + } +} diff --git a/src/Domain/Gifts/Models/Gift.php b/src/Domain/Gifts/Models/Gift.php new file mode 100644 index 0000000..a5e328d --- /dev/null +++ b/src/Domain/Gifts/Models/Gift.php @@ -0,0 +1,57 @@ +belongsTo(User::class); + } + + public function senderUser(): BelongsTo + { + return $this->belongsTo(User::class, 'sender_user_id'); + } + + /** + * Create a new factory instance for the model. + */ + protected static function newFactory(): Factory + { + return DBGiftFactory::new(); + } +} diff --git a/src/Domain/Gifts/QueryBuilders/GiftQueryBuilder.php b/src/Domain/Gifts/QueryBuilders/GiftQueryBuilder.php new file mode 100644 index 0000000..ecc2064 --- /dev/null +++ b/src/Domain/Gifts/QueryBuilders/GiftQueryBuilder.php @@ -0,0 +1,30 @@ +where('id', $id); + } + + public function whereUser(User $user): self + { + return $this->where('user_id', $user->getKey()); + } + + public function whereSenderUser(User $user): self + { + return $this->where('sender_user_id', $user->getKey()); + } +} diff --git a/src/Domain/Users/Models/User.php b/src/Domain/Users/Models/User.php index 4779828..22cca13 100644 --- a/src/Domain/Users/Models/User.php +++ b/src/Domain/Users/Models/User.php @@ -3,6 +3,7 @@ namespace Domain\Users\Models; use Database\Factories\DBUserFactory; +use Domain\Gifts\Models\Gift; use Domain\Pockets\Models\Pocket; use Domain\Quotes\Models\Quote; use Domain\Rating\Contracts\Rates; @@ -14,7 +15,6 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; -use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -42,7 +42,9 @@ * @property ?int $roles_count * * @property-read HasMany $quotes - * @property-read HasOne $pocket + * @property-read HasMany $gifts + * @property-read HasMany $sentGifts + * @property-read Pocket $pocket * * @method static DBUserFactory factory(...$parameters) * @method static UserQueryBuilder query() @@ -90,6 +92,16 @@ public function pocket(): BelongsTo return $this->belongsTo(Pocket::class); } + public function gifts(): HasMany + { + return $this->hasMany(Gift::class); + } + + public function sentGifts(): HasMany + { + return $this->hasMany(Gift::class, 'sender_user_id'); + } + /** * Create a new factory instance for the model. */ diff --git a/tests/Feature/ApiAdmin/Users/IndexUserTest.php b/tests/Feature/ApiAdmin/Users/IndexUserTest.php index 7b71c4a..e382e7e 100644 --- a/tests/Feature/ApiAdmin/Users/IndexUserTest.php +++ b/tests/Feature/ApiAdmin/Users/IndexUserTest.php @@ -151,7 +151,7 @@ fn ($query) => $query->toContain('select `roles`.*, `role_has_permissions`.`permission_id` as `pivot_permission_id`, `role_has_permissions`.`role_id` as `pivot_role_id` from `roles` inner join `role_has_permissions` on `roles`.`id` = `role_has_permissions`.`role_id` where `role_has_permissions`.`permission_id` in'), fn ($query) => $query->toBe('select `permissions`.*, `model_has_permissions`.`model_id` as `pivot_model_id`, `model_has_permissions`.`permission_id` as `pivot_permission_id`, `model_has_permissions`.`model_type` as `pivot_model_type` from `permissions` inner join `model_has_permissions` on `permissions`.`id` = `model_has_permissions`.`permission_id` where `model_has_permissions`.`model_id` = ? and `model_has_permissions`.`model_type` = ?'), fn ($query) => $query->toBe('select count(*) as aggregate from `users` where `users`.`deleted_at` is null'), - fn ($query) => $query->toBe('select `id`, `name`, `email`, `deleted_at`, `created_at`, `updated_at` from `users` where `users`.`deleted_at` is null order by `created_at` asc limit 20 offset 0'), + fn ($query) => $query->toBe('select `id`, `name`, `email`, `pocket_id`, `deleted_at`, `created_at`, `updated_at` from `users` where `users`.`deleted_at` is null order by `created_at` asc limit 20 offset 0'), ); DB::disableQueryLog(); diff --git a/tests/Unit/Gifts/Actions/StoreGiftActionTest.php b/tests/Unit/Gifts/Actions/StoreGiftActionTest.php new file mode 100644 index 0000000..58216d1 --- /dev/null +++ b/tests/Unit/Gifts/Actions/StoreGiftActionTest.php @@ -0,0 +1,67 @@ +pocket = Pocket::factory()->create(['balance' => 0, 'currency' => 'USD']); + $this->user = User::factory()->create(['pocket_id' => $this->pocket->id]); + + $this->senderUser = User::factory()->create(); +}); + +it('can create a gift and user pocket received the money', function () { + $data = new StoreGiftData(note: 'Thanks for your work', amount: 1_500_00, currency: 'USD'); + + $gift = (new StoreGiftAction())->__invoke(data: $data, senderUser: $this->senderUser, user: $this->user); + + expect($gift) /* @phpstan-ignore-line */ + ->senderUser->toEqual($this->senderUser) + ->user->toEqual($this->user) + ->note->toEqual('Thanks for your work') + ->amount->toEqual(1_500_00) + ->currency->toEqual('USD'); + + $this->pocket->refresh(); + + assertEquals(1_500_00, $this->pocket->balance); +}); + +it('cannot create a gift with different currency', function () { + $data = new StoreGiftData(note: 'Thanks for your work', amount: 1_500_00, currency: 'MXN'); + + try { + (new StoreGiftAction())->__invoke(data: $data, senderUser: $this->senderUser, user: $this->user); + } catch (\DomainException $e) { + assertEquals('User pocket currency does not match gift currency', $e->getMessage()); + } + + assertEquals(0, Gift::query()->count()); + + $this->pocket->refresh(); + + assertEquals(0, $this->pocket->balance); +}); + +test('sql queries optimization test', function () { + DB::enableQueryLog(); + + $data = new StoreGiftData(note: 'Thanks for your work', amount: 1_500_00, currency: 'USD'); + + (new StoreGiftAction())->__invoke(data: $data, senderUser: $this->senderUser, user: $this->user); + + expect(formatQueries(DB::getQueryLog())) + ->toHaveCount(3) + ->sequence( + fn ($query) => $query->toBe('select `id`, `balance`, `currency` from `pockets` where `id` = ? and `pockets`.`deleted_at` is null limit 1'), + fn ($query) => $query->toBe('update `pockets` set `balance` = ?, `pockets`.`updated_at` = ? where `id` = ?'), + fn ($query) => $query->toBe('insert into `gifts` (`note`, `amount`, `currency`, `sender_user_id`, `user_id`, `updated_at`, `created_at`) values (?, ?, ?, ?, ?, ?, ?)'), + ); + + DB::disableQueryLog(); +}); diff --git a/tests/Unit/Gifts/Models/GiftTest.php b/tests/Unit/Gifts/Models/GiftTest.php new file mode 100644 index 0000000..aeaeb1f --- /dev/null +++ b/tests/Unit/Gifts/Models/GiftTest.php @@ -0,0 +1,22 @@ +user()->associate($user); + + expect($gift->user)->toBeInstanceOf(User::class); +}); + +it('gets sender user as single model', function () { + $gift = new Gift(); + $senderUser = new User(); + + $gift->senderUser()->associate($senderUser); + + expect($gift->senderUser)->toBeInstanceOf(User::class); +}); diff --git a/tests/Unit/Users/Models/UserTest.php b/tests/Unit/Users/Models/UserTest.php index 4bfcf9e..268b2ab 100644 --- a/tests/Unit/Users/Models/UserTest.php +++ b/tests/Unit/Users/Models/UserTest.php @@ -12,6 +12,18 @@ expect($user->quotes)->toBeInstanceOf(Collection::class); }); +it('get gifts as collection', function () { + $user = new User(); + + expect($user->gifts)->toBeInstanceOf(Collection::class); +}); + +it('get sent gifts as collection', function () { + $user = new User(); + + expect($user->sentGifts)->toBeInstanceOf(Collection::class); +}); + it('get pocket as single model', function () { $user = new User(); $pocket = new Pocket();