diff --git a/src/app/Console/Commands/DiscountList.php b/src/app/Console/Commands/DiscountList.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/DiscountList.php @@ -0,0 +1,61 @@ +orderBy('discount')->get()->each( + function ($discount) { + $name = $discount->description; + + if ($discount->code) { + $name .= " [{$discount->code}]"; + } + + $this->info( + sprintf( + "%s %3d%% %s", + $discount->id, + $discount->discount, + $name + ) + ); + } + ); + } +} diff --git a/src/app/Console/Commands/UserDiscount.php b/src/app/Console/Commands/UserDiscount.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/UserDiscount.php @@ -0,0 +1,68 @@ +argument('user'))->first(); + + if (!$user) { + return 1; + } + + $this->info("Found user {$user->id}"); + + if ($this->argument('discount') === '0') { + $discount = null; + } else { + $discount = \App\Discount::find($this->argument('discount')); + + if (!$discount) { + return 1; + } + } + + foreach ($user->wallets as $wallet) { + if (!$discount) { + $wallet->discount()->dissociate(); + } else { + $wallet->discount()->associate($discount); + } + + $wallet->save(); + } + } +} diff --git a/src/app/Console/Commands/UserWallets.php b/src/app/Console/Commands/UserWallets.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/UserWallets.php @@ -0,0 +1,50 @@ +argument('user'))->first(); + + if (!$user) { + return 1; + } + + foreach ($user->wallets as $wallet) { + $this->info("{$wallet->id} {$wallet->description}"); + } + } +} diff --git a/src/app/Console/Commands/WalletDiscount.php b/src/app/Console/Commands/WalletDiscount.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/WalletDiscount.php @@ -0,0 +1,62 @@ +argument('wallet'))->first(); + + if (!$wallet) { + return 1; + } + + // FIXME: Using '0' for delete might be not that obvious + + if ($this->argument('discount') === '0') { + $wallet->discount()->dissociate(); + } else { + $discount = \App\Discount::find($this->argument('discount')); + + if (!$discount) { + return 1; + } + + $wallet->discount()->associate($discount); + } + + $wallet->save(); + } +} diff --git a/src/app/Discount.php b/src/app/Discount.php new file mode 100644 --- /dev/null +++ b/src/app/Discount.php @@ -0,0 +1,59 @@ + 'integer', + ]; + + protected $fillable = [ + 'active', + 'code', + 'description', + 'discount', + ]; + + /** @var array Translatable properties */ + public $translatable = [ + 'description', + ]; + + /** + * Discount value mutator + * + * @throws \Exception + */ + public function setDiscountAttribute($discount) + { + $discount = (int) $discount; + + if ($discount < 0 || $discount > 100) { + throw new \Exception("Invalid discount value, expected integer in range of 0-100"); + } + + $this->attributes['discount'] = $discount; + } + + /** + * List of wallets with this discount assigned. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function wallets() + { + return $this->hasMany('App\Wallet'); + } +} diff --git a/src/app/Http/Controllers/API/UsersController.php b/src/app/Http/Controllers/API/UsersController.php --- a/src/app/Http/Controllers/API/UsersController.php +++ b/src/app/Http/Controllers/API/UsersController.php @@ -481,10 +481,22 @@ $response = array_merge($response, self::userStatuses($user)); + // Add discount info to wallet object output + $map_func = function ($wallet) { + $result = $wallet->toArray(); + + if ($wallet->discount) { + $result['discount'] = $wallet->discount->discount; + $result['discount_description'] = $wallet->discount->description; + } + + return $result; + }; + // Information about wallets and accounts for access checks - $response['wallets'] = $user->wallets->toArray(); - $response['accounts'] = $user->accounts->toArray(); - $response['wallet'] = $user->wallet()->toArray(); + $response['wallets'] = $user->wallets->map($map_func)->toArray(); + $response['accounts'] = $user->accounts->map($map_func)->toArray(); + $response['wallet'] = $map_func($user->wallet()); return $response; } diff --git a/src/app/Observers/DiscountObserver.php b/src/app/Observers/DiscountObserver.php new file mode 100644 --- /dev/null +++ b/src/app/Observers/DiscountObserver.php @@ -0,0 +1,29 @@ +{$discount->getKeyName()} = $allegedly_unique; + break; + } + } + } +} diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -24,6 +24,7 @@ */ public function boot() { + \App\Discount::observe(\App\Observers\DiscountObserver::class); \App\Domain::observe(\App\Observers\DomainObserver::class); \App\Entitlement::observe(\App\Observers\EntitlementObserver::class); \App\Package::observe(\App\Observers\PackageObserver::class); diff --git a/src/app/Wallet.php b/src/app/Wallet.php --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -33,7 +33,7 @@ ]; protected $nullable = [ - 'description' + 'description', ]; protected $casts = [ @@ -59,6 +59,8 @@ public function chargeEntitlements($apply = true) { $charges = 0; + $discount = $this->discount ? $this->discount->discount : 0; + $discount = (100 - $discount) / 100; foreach ($this->entitlements()->get()->fresh() as $entitlement) { // This entitlement has been created less than or equal to 14 days ago (this is at @@ -76,7 +78,9 @@ if ($entitlement->updated_at <= Carbon::now()->subMonthsWithoutOverflow(1)) { $diff = $entitlement->updated_at->diffInMonths(Carbon::now()); - $charges += $entitlement->cost * $diff; + $cost = (int) ($entitlement->cost * $discount * $diff); + + $charges += $cost; // if we're in dry-run, you know... if (!$apply) { @@ -86,13 +90,25 @@ $entitlement->updated_at = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diff); $entitlement->save(); - $this->debit($entitlement->cost * $diff); + // TODO: This would be better done out of the loop (debit() will call save()), + // but then, maybe we should use a db transaction + $this->debit($cost); } } return $charges; } + /** + * The discount assigned to the wallet. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function discount() + { + return $this->belongsTo('App\Discount', 'discount_id', 'id'); + } + /** * Calculate the expected charges to this wallet. * diff --git a/src/database/migrations/2020_03_30_100000_create_discounts.php b/src/database/migrations/2020_03_30_100000_create_discounts.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2020_03_30_100000_create_discounts.php @@ -0,0 +1,59 @@ +string('id', 36); + $table->tinyInteger('discount')->unsigned(); + $table->json('description'); + $table->string('code', 32)->nullable(); + $table->boolean('active')->default(false); + $table->timestamps(); + + $table->primary('id'); + } + ); + + Schema::table( + 'wallets', + function (Blueprint $table) { + $table->string('discount_id', 36)->nullable(); + + $table->foreign('discount_id')->references('id') + ->on('discounts')->onDelete('set null'); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table( + 'wallets', + function (Blueprint $table) { + $table->dropForeign(['discount_id']); + $table->dropColumn('discount_id'); + } + ); + + Schema::dropIfExists('discounts'); + } +} diff --git a/src/database/seeds/DatabaseSeeder.php b/src/database/seeds/DatabaseSeeder.php --- a/src/database/seeds/DatabaseSeeder.php +++ b/src/database/seeds/DatabaseSeeder.php @@ -13,6 +13,7 @@ { $this->call( [ + DiscountSeeder::class, DomainSeeder::class, SkuSeeder::class, PackageSeeder::class, diff --git a/src/database/seeds/DiscountSeeder.php b/src/database/seeds/DiscountSeeder.php new file mode 100644 --- /dev/null +++ b/src/database/seeds/DiscountSeeder.php @@ -0,0 +1,40 @@ + 'Free Account', + 'discount' => 100, + 'active' => true, + ] + ); + + Discount::create( + [ + 'description' => 'Student or Educational Institution', + 'discount' => 30, + 'active' => true, + ] + ); + + Discount::create( + [ + 'description' => 'Test voucher', + 'discount' => 10, + 'active' => true, + 'code' => 'TEST', + ] + ); + } +} diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue --- a/src/resources/vue/User/Info.vue +++ b/src/resources/vue/User/Info.vue @@ -69,7 +69,7 @@ {{ pkg.name }} - {{ $root.price(pkg.cost) + '/month' }} + {{ price(pkg.cost) }} @@ -142,6 +150,8 @@ export default { data() { return { + discount: 0, + discount_description: '', user_id: null, user: {}, packages: [], @@ -152,6 +162,17 @@ created() { this.user_id = this.$route.params.user + let wallet = this.$store.state.authInfo.accounts[0] + + if (!wallet) { + wallet = this.$store.state.authInfo.wallets[0] + } + + if (wallet && wallet.discount) { + this.discount = wallet.discount + this.discount_description = wallet.discount_description + } + if (this.user_id === 'new') { // do nothing (for now) axios.get('/api/v4/packages') @@ -167,6 +188,8 @@ this.user = response.data this.user.first_name = response.data.settings.first_name this.user.last_name = response.data.settings.last_name + this.discount = this.user.wallet.discount + this.discount_description = this.user.wallet.discount_description $('#aliases').val(response.data.aliases.join("\n")) listinput('#aliases') @@ -310,12 +333,13 @@ let record = input.parents('tr').first() let sku_id = record.find('input[type=checkbox]').val() let sku = this.findSku(sku_id) + let cost = sku.cost // Update the label input.prev().text(value + ' ' + sku.range.unit) // Update the price - record.find('.price').text(this.$root.price(sku.cost * (value - sku.units_free)) + '/month') + record.find('.price').text(this.price(cost, value - sku.units_free)) }, findSku(id) { for (let i = 0; i < this.skus.length; i++) { @@ -323,6 +347,16 @@ return this.skus[i]; } } + }, + price(cost, units = 1) { + let index = '' + + if (this.discount) { + cost = Math.floor(cost * ((100 - this.discount) / 100)) + index = '\u00B9' + } + + return this.$root.price(cost * units) + '/month' + index } } } diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php --- a/src/tests/Browser/UsersTest.php +++ b/src/tests/Browser/UsersTest.php @@ -2,6 +2,7 @@ namespace Tests\Browser; +use App\Discount; use App\Entitlement; use App\Sku; use App\User; @@ -40,6 +41,10 @@ ->where('alias', 'john.test@kolab.org')->delete(); Entitlement::where('entitleable_id', $john->id)->whereIn('cost', [25, 100])->delete(); + + $wallet = $john->wallets()->first(); + $wallet->discount()->dissociate(); + $wallet->save(); } /** @@ -56,6 +61,10 @@ Entitlement::where('entitleable_id', $john->id)->whereIn('cost', [25, 100])->delete(); + $wallet = $john->wallets()->first(); + $wallet->discount()->dissociate(); + $wallet->save(); + parent::tearDown(); } @@ -97,7 +106,7 @@ ->click('@links .link-users') ->on(new UserList()) ->whenAvailable('@table', function (Browser $browser) { - $browser->assertElementsCount('tbody tr', 3) + $browser->assertElementsCount('tbody tr', 4) ->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org') ->assertSeeIn('tbody tr:nth-child(2) a', 'john@kolab.org') ->assertSeeIn('tbody tr:nth-child(3) a', 'ned@kolab.org') @@ -270,6 +279,7 @@ ) ->click('tbody tr:nth-child(5) td.selection input'); }) + ->assertMissing('@skus table + .hint') ->click('button[type=submit]'); }) ->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { @@ -348,11 +358,14 @@ $browser->assertElementsCount('tbody tr', 2) ->assertSeeIn('tbody tr:nth-child(1)', 'Groupware Account') ->assertSeeIn('tbody tr:nth-child(2)', 'Lite Account') + ->assertSeeIn('tbody tr:nth-child(1) .price', '9,99 CHF/month') + ->assertSeeIn('tbody tr:nth-child(2) .price', '4,44 CHF/month') ->assertChecked('tbody tr:nth-child(1) input') ->click('tbody tr:nth-child(2) input') ->assertNotChecked('tbody tr:nth-child(1) input') ->assertChecked('tbody tr:nth-child(2) input'); }) + ->assertMissing('@packages table + .hint') ->assertSeeIn('button[type=submit]', 'Submit'); // Test browser-side required fields and error handling @@ -510,4 +523,63 @@ // TODO: Test what happens with the logged in user session after he's been deleted by another user } + + /** + * Test discounted sku/package prices in the UI + */ + public function testDiscountedPrices(): void + { + // Add 10% discount + $discount = Discount::where('code', 'TEST')->first(); + $john = User::where('email', 'john@kolab.org')->first(); + $wallet = $john->wallet(); + $wallet->discount()->associate($discount); + $wallet->save(); + + // SKUs on user edit page + $this->browse(function (Browser $browser) { + $browser->visit('/logout') + ->on(new Home()) + ->submitLogon('john@kolab.org', 'simple123', true) + ->visit(new UserList()) + ->click('@table tr:nth-child(2) a') + ->on(new UserInfo()) + ->with('@form', function (Browser $browser) { + $browser->whenAvailable('@skus', function (Browser $browser) { + $quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input'); + $browser->assertElementsCount('tbody tr', 5) + // Mailbox SKU + ->assertSeeIn('tbody tr:nth-child(1) td.price', '3,99 CHF/month¹') + // Storage SKU + ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹') + ->with($quota_input, function (Browser $browser) { + $browser->setQuotaValue(100); + }) + ->assertSeeIn('tr:nth-child(2) td.price', '21,56 CHF/month¹') + // groupware SKU + ->assertSeeIn('tbody tr:nth-child(3) td.price', '4,99 CHF/month¹') + // 2FA SKU + ->assertSeeIn('tbody tr:nth-child(4) td.price', '0,00 CHF/month¹') + // ActiveSync SKU + ->assertSeeIn('tbody tr:nth-child(5) td.price', '0,90 CHF/month¹'); + }) + ->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher'); + }); + }); + + // Packages on new user page + $this->browse(function (Browser $browser) { + $browser->visit(new UserList()) + ->click('button.create-user') + ->on(new UserInfo()) + ->with('@form', function (Browser $browser) { + $browser->whenAvailable('@packages', function (Browser $browser) { + $browser->assertElementsCount('tbody tr', 2) + ->assertSeeIn('tbody tr:nth-child(1) .price', '8,99 CHF/month¹') // Groupware + ->assertSeeIn('tbody tr:nth-child(2) .price', '3,99 CHF/month¹'); // Lite + }) + ->assertSeeIn('@packages table + .hint', '¹ applied discount: 10% - Test voucher'); + }); + }); + } } diff --git a/src/tests/Feature/BillingTest.php b/src/tests/Feature/BillingTest.php --- a/src/tests/Feature/BillingTest.php +++ b/src/tests/Feature/BillingTest.php @@ -204,7 +204,7 @@ $this->assertEquals(2023, $this->wallet->expectedCharges()); } - public function testWithDiscount(): void + public function testWithDiscountRate(): void { $package = \App\Package::create( [ @@ -241,4 +241,19 @@ $this->assertEquals(500, $wallet->expectedCharges()); } + + /** + * Test cost calculation with a wallet discount + */ + public function testWithWalletDiscount(): void + { + $discount = \App\Discount::where('code', 'TEST')->first(); + + $wallet = $this->user->wallets()->first(); + $wallet->discount()->associate($discount); + + $this->backdateEntitlements($wallet->entitlements, Carbon::now()->subMonthsWithoutOverflow(1)); + + $this->assertEquals(898, $wallet->expectedCharges()); + } } diff --git a/src/tests/Feature/Console/DiscountListTest.php b/src/tests/Feature/Console/DiscountListTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Console/DiscountListTest.php @@ -0,0 +1,16 @@ +artisan('discount:list') + ->assertExitCode(0); + + $this->markTestIncomplete(); + } +} diff --git a/src/tests/Feature/Console/UserDiscountTest.php b/src/tests/Feature/Console/UserDiscountTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Console/UserDiscountTest.php @@ -0,0 +1,13 @@ +markTestIncomplete(); + } +} diff --git a/src/tests/Feature/Console/UserWalletsTest.php b/src/tests/Feature/Console/UserWalletsTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Console/UserWalletsTest.php @@ -0,0 +1,16 @@ +artisan('user:wallets john@kolab.org') + ->assertExitCode(0); + + $this->markTestIncomplete(); + } +} diff --git a/src/tests/Feature/Console/WalletDiscountTest.php b/src/tests/Feature/Console/WalletDiscountTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Console/WalletDiscountTest.php @@ -0,0 +1,13 @@ +markTestIncomplete(); + } +} diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php --- a/src/tests/Feature/Controller/UsersTest.php +++ b/src/tests/Feature/Controller/UsersTest.php @@ -2,11 +2,13 @@ namespace Tests\Feature\Controller; +use App\Discount; use App\Domain; use App\Http\Controllers\API\UsersController; use App\Package; use App\Sku; use App\User; +use App\Wallet; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Str; use Tests\TestCase; @@ -26,6 +28,11 @@ $this->deleteTestUser('UserEntitlement2A@UserEntitlement.com'); $this->deleteTestUser('john2.doe2@kolab.org'); $this->deleteTestDomain('userscontroller.com'); + + $user = $this->getTestUser('john@kolab.org'); + $wallet = $user->wallets()->first(); + $wallet->discount()->dissociate(); + $wallet->save(); } /** @@ -40,6 +47,11 @@ $this->deleteTestUser('john2.doe2@kolab.org'); $this->deleteTestDomain('userscontroller.com'); + $user = $this->getTestUser('john@kolab.org'); + $wallet = $user->wallets()->first(); + $wallet->discount()->dissociate(); + $wallet->save(); + parent::tearDown(); } @@ -183,6 +195,7 @@ $response->assertStatus(401); $jack = $this->getTestUser('jack@kolab.org'); + $joe = $this->getTestUser('joe@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); @@ -198,10 +211,11 @@ $json = $response->json(); - $this->assertCount(3, $json); + $this->assertCount(4, $json); $this->assertSame($jack->email, $json[0]['email']); - $this->assertSame($john->email, $json[1]['email']); - $this->assertSame($ned->email, $json[2]['email']); + $this->assertSame($joe->email, $json[1]['email']); + $this->assertSame($john->email, $json[2]['email']); + $this->assertSame($ned->email, $json[3]['email']); // Values below are tested by Unit tests $this->assertArrayHasKey('isDeleted', $json[0]); $this->assertArrayHasKey('isSuspended', $json[0]); @@ -214,10 +228,11 @@ $json = $response->json(); - $this->assertCount(3, $json); + $this->assertCount(4, $json); $this->assertSame($jack->email, $json[0]['email']); - $this->assertSame($john->email, $json[1]['email']); - $this->assertSame($ned->email, $json[2]['email']); + $this->assertSame($joe->email, $json[1]['email']); + $this->assertSame($john->email, $json[2]['email']); + $this->assertSame($ned->email, $json[3]['email']); } /** @@ -382,6 +397,7 @@ $this->assertCount(0, $result['accounts']); $this->assertCount(1, $result['wallets']); $this->assertSame($wallet->id, $result['wallet']['id']); + $this->assertArrayNotHasKey('discount', $result['wallet']); $ned = $this->getTestUser('ned@kolab.org'); $ned_wallet = $ned->wallets()->first(); @@ -396,6 +412,22 @@ $this->assertSame($wallet->id, $result['wallet']['id']); $this->assertSame($wallet->id, $result['accounts'][0]['id']); $this->assertSame($ned_wallet->id, $result['wallets'][0]['id']); + + // Test discount in a response + $discount = Discount::where('code', 'TEST')->first(); + $wallet->discount()->associate($discount); + $wallet->save(); + $user->refresh(); + + $result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]); + + $this->assertEquals($user->id, $result['id']); + $this->assertSame($discount->id, $result['wallet']['discount_id']); + $this->assertSame($discount->discount, $result['wallet']['discount']); + $this->assertSame($discount->description, $result['wallet']['discount_description']); + $this->assertSame($discount->id, $result['wallets'][0]['discount_id']); + $this->assertSame($discount->discount, $result['wallets'][0]['discount']); + $this->assertSame($discount->description, $result['wallets'][0]['discount_description']); } /** @@ -452,7 +484,7 @@ $mailbox_sku = Sku::where('title', 'mailbox')->first(); $secondfactor_sku = Sku::where('title', '2fa')->first(); - $this->assertCount(4, $json['skus']); + $this->assertCount(5, $json['skus']); $this->assertSame(2, $json['skus'][$storage_sku->id]['count']); $this->assertSame(1, $json['skus'][$groupware_sku->id]['count']); $this->assertSame(1, $json['skus'][$mailbox_sku->id]['count']); diff --git a/src/tests/Feature/DiscountTest.php b/src/tests/Feature/DiscountTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/DiscountTest.php @@ -0,0 +1,31 @@ +expectException(\Exception::class); + + $discount = new Discount(); + $discount->discount = -1; + } + + /** + * Test setting discount value + */ + public function testDiscountValueMoreThanHundred(): void + { + $this->expectException(\Exception::class); + + $discount = new Discount(); + $discount->discount = 101; + } +} diff --git a/src/tests/Feature/SkuTest.php b/src/tests/Feature/SkuTest.php --- a/src/tests/Feature/SkuTest.php +++ b/src/tests/Feature/SkuTest.php @@ -48,7 +48,7 @@ public function testSkuEntitlements(): void { - $this->assertCount(3, Sku::where('title', 'mailbox')->first()->entitlements); + $this->assertCount(4, Sku::where('title', 'mailbox')->first()->entitlements); } public function testSkuPackages(): void diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php --- a/src/tests/Feature/UserTest.php +++ b/src/tests/Feature/UserTest.php @@ -340,20 +340,23 @@ */ public function testUsers(): void { - $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); + $joe = $this->getTestUser('joe@kolab.org'); + $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $wallet = $john->wallets()->first(); $users = $john->users()->orderBy('email')->get(); - $this->assertCount(3, $users); + $this->assertCount(4, $users); $this->assertEquals($jack->id, $users[0]->id); - $this->assertEquals($john->id, $users[1]->id); - $this->assertEquals($ned->id, $users[2]->id); + $this->assertEquals($joe->id, $users[1]->id); + $this->assertEquals($john->id, $users[2]->id); + $this->assertEquals($ned->id, $users[3]->id); $this->assertSame($wallet->id, $users[0]->wallet_id); $this->assertSame($wallet->id, $users[1]->wallet_id); $this->assertSame($wallet->id, $users[2]->wallet_id); + $this->assertSame($wallet->id, $users[3]->wallet_id); $users = $jack->users()->orderBy('email')->get(); @@ -361,7 +364,7 @@ $users = $ned->users()->orderBy('email')->get(); - $this->assertCount(3, $users); + $this->assertCount(4, $users); } public function testWallets(): void