diff --git a/src/app/Console/Commands/WalletAddTransaction.php b/src/app/Console/Commands/Wallet/AddTransactionCommand.php similarity index 76% rename from src/app/Console/Commands/WalletAddTransaction.php rename to src/app/Console/Commands/Wallet/AddTransactionCommand.php index 2c52a811..907d66c3 100644 --- a/src/app/Console/Commands/WalletAddTransaction.php +++ b/src/app/Console/Commands/Wallet/AddTransactionCommand.php @@ -1,46 +1,47 @@ getWallet($this->argument('wallet')); if (!$wallet) { + $this->error("Wallet not found."); return 1; } $qty = (int) $this->argument('qty'); - $message = $this->option('message'); + $message = (string) $this->option('message'); if ($qty < 0) { - $wallet->debit($qty, $message); + $wallet->debit(-$qty, $message); } else { $wallet->credit($qty, $message); } } } diff --git a/src/app/Console/Commands/WalletBalances.php b/src/app/Console/Commands/Wallet/BalancesCommand.php similarity index 81% rename from src/app/Console/Commands/WalletBalances.php rename to src/app/Console/Commands/Wallet/BalancesCommand.php index d0821f4b..12d0a48b 100644 --- a/src/app/Console/Commands/WalletBalances.php +++ b/src/app/Console/Commands/Wallet/BalancesCommand.php @@ -1,60 +1,54 @@ join('users', 'users.id', '=', 'wallets.user_id') ->withEnvTenantContext('users') - ->all(); + ->where('balance', '!=', '0') + ->whereNull('users.deleted_at') + ->orderBy('balance'); $wallets->each( function ($wallet) { - if ($wallet->balance == 0) { - return; - } - $user = $wallet->owner; - if (!$user) { - return; - } - $this->info( sprintf( "%s: %8s (account: %s/%s (%s))", $wallet->id, $wallet->balance, "https://kolabnow.com/cockpit/admin/accounts/show", $user->id, $user->email ) ); } ); } } diff --git a/src/app/Console/Commands/WalletCharge.php b/src/app/Console/Commands/Wallet/ChargeCommand.php similarity index 81% rename from src/app/Console/Commands/WalletCharge.php rename to src/app/Console/Commands/Wallet/ChargeCommand.php index 6ca62cb0..268ae556 100644 --- a/src/app/Console/Commands/WalletCharge.php +++ b/src/app/Console/Commands/Wallet/ChargeCommand.php @@ -1,67 +1,72 @@ argument('wallet')) { // Find specified wallet by ID $wallet = $this->getWallet($wallet); - if (!$wallet || !$wallet->owner) { + if (!$wallet) { + $this->error("Wallet not found."); + return 1; + } + + if (!$wallet->owner) { + $this->error("Wallet's owner is deleted."); return 1; } $wallets = [$wallet]; } else { // Get all wallets, excluding deleted accounts - $wallets = Wallet::select('wallets.*') + $wallets = \App\Wallet::select('wallets.*') ->join('users', 'users.id', '=', 'wallets.user_id') ->withEnvTenantContext('users') ->whereNull('users.deleted_at') ->cursor(); } foreach ($wallets as $wallet) { $charge = $wallet->chargeEntitlements(); if ($charge > 0) { $this->info( "Charged wallet {$wallet->id} for user {$wallet->owner->email} with {$charge}" ); // Top-up the wallet if auto-payment enabled for the wallet \App\Jobs\WalletCharge::dispatch($wallet); } if ($wallet->balance < 0) { // Check the account balance, send notifications, suspend, delete \App\Jobs\WalletCheck::dispatch($wallet); } } } } diff --git a/src/app/Console/Commands/WalletExpected.php b/src/app/Console/Commands/Wallet/ExpectedCommand.php similarity index 80% rename from src/app/Console/Commands/WalletExpected.php rename to src/app/Console/Commands/Wallet/ExpectedCommand.php index 8bd2a523..a799a0f4 100644 --- a/src/app/Console/Commands/WalletExpected.php +++ b/src/app/Console/Commands/Wallet/ExpectedCommand.php @@ -1,68 +1,64 @@ option('user')) { $user = $this->getUser($this->option('user')); if (!$user) { + $this->error("User not found."); return 1; } $wallets = $user->wallets; } else { $wallets = \App\Wallet::select('wallets.*') ->join('users', 'users.id', '=', 'wallets.user_id') ->withEnvTenantContext('users') - ->all(); + ->whereNull('users.deleted_at'); } - foreach ($wallets as $wallet) { + $wallets->each(function ($wallet) { $charge = 0; $expected = $wallet->expectedCharges(); - if (!$wallet->owner) { - \Log::debug("{$wallet->id} has no owner: {$wallet->user_id}"); - continue; - } - if ($this->option('non-zero') && $expected < 1) { - continue; + return; } $this->info( sprintf( "expect charging wallet %s for user %s with %d", $wallet->id, $wallet->owner->email, $expected ) ); - } + }); } } diff --git a/src/app/Console/Commands/WalletGetBalance.php b/src/app/Console/Commands/Wallet/GetBalanceCommand.php similarity index 83% rename from src/app/Console/Commands/WalletGetBalance.php rename to src/app/Console/Commands/Wallet/GetBalanceCommand.php index 661177dc..e459d604 100644 --- a/src/app/Console/Commands/WalletGetBalance.php +++ b/src/app/Console/Commands/Wallet/GetBalanceCommand.php @@ -1,38 +1,39 @@ getWallet($this->argument('wallet')); if (!$wallet) { + $this->error("Wallet not found."); return 1; } $this->info($wallet->balance); } } diff --git a/src/app/Console/Commands/WalletGetDiscount.php b/src/app/Console/Commands/Wallet/GetDiscountCommand.php similarity index 85% rename from src/app/Console/Commands/WalletGetDiscount.php rename to src/app/Console/Commands/Wallet/GetDiscountCommand.php index 6fdbf0ca..52378f67 100644 --- a/src/app/Console/Commands/WalletGetDiscount.php +++ b/src/app/Console/Commands/Wallet/GetDiscountCommand.php @@ -1,43 +1,44 @@ getWallet($this->argument('wallet')); if (!$wallet) { + $this->error("Wallet not found."); return 1; } if (!$wallet->discount) { $this->info("No discount on this wallet."); return 0; } $this->info($wallet->discount->discount); } } diff --git a/src/app/Console/Commands/WalletMandate.php b/src/app/Console/Commands/Wallet/MandateCommand.php similarity index 88% rename from src/app/Console/Commands/WalletMandate.php rename to src/app/Console/Commands/Wallet/MandateCommand.php index f13265c5..c81d5f7c 100644 --- a/src/app/Console/Commands/WalletMandate.php +++ b/src/app/Console/Commands/Wallet/MandateCommand.php @@ -1,60 +1,61 @@ getWallet($this->argument('wallet')); if (!$wallet) { + $this->error("Wallet not found."); return 1; } $mandate = PaymentsController::walletMandate($wallet); if (!empty($mandate['id'])) { $disabled = $mandate['isDisabled'] ? 'Yes' : 'No'; if ($this->option('disable') && $disabled == 'No') { $wallet->setSetting('mandate_disabled', 1); $disabled = 'Yes'; } elseif ($this->option('enable') && $disabled == 'Yes') { $wallet->setSetting('mandate_disabled', null); $disabled = 'No'; } $this->info("Auto-payment: {$mandate['method']}"); $this->info(" id: {$mandate['id']}"); $this->info(" status: " . ($mandate['isPending'] ? 'pending' : 'valid')); $this->info(" amount: {$mandate['amount']} {$wallet->currency}"); $this->info(" min-balance: {$mandate['balance']} {$wallet->currency}"); $this->info(" disabled: $disabled"); } else { $this->info("Auto-payment: none"); } } } diff --git a/src/app/Console/Commands/WalletSetBalance.php b/src/app/Console/Commands/Wallet/SetBalanceCommand.php similarity index 84% rename from src/app/Console/Commands/WalletSetBalance.php rename to src/app/Console/Commands/Wallet/SetBalanceCommand.php index d3a082bc..7ed57996 100644 --- a/src/app/Console/Commands/WalletSetBalance.php +++ b/src/app/Console/Commands/Wallet/SetBalanceCommand.php @@ -1,39 +1,40 @@ getWallet($this->argument('wallet')); if (!$wallet) { + $this->error("Wallet not found."); return 1; } $wallet->balance = (int) $this->argument('balance'); $wallet->save(); } } diff --git a/src/app/Console/Commands/WalletSetDiscount.php b/src/app/Console/Commands/Wallet/SetDiscountCommand.php similarity index 85% rename from src/app/Console/Commands/WalletSetDiscount.php rename to src/app/Console/Commands/Wallet/SetDiscountCommand.php index 50e4e9d6..ee48af92 100644 --- a/src/app/Console/Commands/WalletSetDiscount.php +++ b/src/app/Console/Commands/Wallet/SetDiscountCommand.php @@ -1,52 +1,54 @@ getWallet($this->argument('wallet')); if (!$wallet) { + $this->error("Wallet not found."); return 1; } // FIXME: Using '0' for delete might be not that obvious if ($this->argument('discount') === '0') { $wallet->discount()->dissociate(); } else { $discount = $this->getObject(\App\Discount::class, $this->argument('discount')); if (!$discount) { + $this->error("Discount not found."); return 1; } $wallet->discount()->associate($discount); } $wallet->save(); } } diff --git a/src/app/Console/Commands/WalletSettingsCommand.php b/src/app/Console/Commands/Wallet/SettingsCommand.php similarity index 68% rename from src/app/Console/Commands/WalletSettingsCommand.php rename to src/app/Console/Commands/Wallet/SettingsCommand.php index a6a7afc2..ac135df0 100644 --- a/src/app/Console/Commands/WalletSettingsCommand.php +++ b/src/app/Console/Commands/Wallet/SettingsCommand.php @@ -1,12 +1,12 @@ getWallet($this->argument('wallet')); if (!$wallet) { + $this->error("Wallet not found."); return 1; } $wallet->transactions()->orderBy('created_at')->each(function ($transaction) { $this->info( sprintf( "%s: %s %s", $transaction->id, $transaction->created_at, $transaction->toString() ) ); if ($this->option('detail')) { $elements = \App\Transaction::where('transaction_id', $transaction->id) ->orderBy('created_at')->get(); foreach ($elements as $element) { $this->info( sprintf( " + %s: %s", $element->id, $element->toString() ) ); } } }); } } diff --git a/src/app/Console/Commands/WalletUntil.php b/src/app/Console/Commands/Wallet/UntilCommand.php similarity index 85% rename from src/app/Console/Commands/WalletUntil.php rename to src/app/Console/Commands/Wallet/UntilCommand.php index 8d4d7eb2..69c97f14 100644 --- a/src/app/Console/Commands/WalletUntil.php +++ b/src/app/Console/Commands/Wallet/UntilCommand.php @@ -1,40 +1,41 @@ getWallet($this->argument('wallet')); if (!$wallet) { + $this->error("Wallet not found."); return 1; } $until = $wallet->balanceLastsUntil(); $this->info("Lasts until: " . ($until ? $until->toDateString() : 'unknown')); } } diff --git a/src/app/Console/Commands/WalletDiscount.php b/src/app/Console/Commands/WalletDiscount.php deleted file mode 100644 index 30327913..00000000 --- a/src/app/Console/Commands/WalletDiscount.php +++ /dev/null @@ -1,52 +0,0 @@ -getWallet($this->argument('wallet')); - - if (!$wallet) { - return 1; - } - - // FIXME: Using '0' for delete might be not that obvious - - if ($this->argument('discount') === '0') { - $wallet->discount()->dissociate(); - } else { - $discount = $this->getObject(\App\Discount::class, $this->argument('discount')); - - if (!$discount) { - return 1; - } - - $wallet->discount()->associate($discount); - } - - $wallet->save(); - } -} diff --git a/src/tests/Feature/Console/Wallet/AddTransactionTest.php b/src/tests/Feature/Console/Wallet/AddTransactionTest.php new file mode 100644 index 00000000..08d29b8d --- /dev/null +++ b/src/tests/Feature/Console/Wallet/AddTransactionTest.php @@ -0,0 +1,61 @@ +deleteTestUser('wallets-controller@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('wallets-controller@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test command run for a specified wallet + */ + public function testHandle(): void + { + $user = $this->getTestUser('wallets-controller@kolabnow.com'); + $wallet = $user->wallets()->first(); + + // Invalid wallet id + $code = \Artisan::call("wallet:add-transaction 123 100"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("Wallet not found.", $output); + + // Add credit + $code = \Artisan::call("wallet:add-transaction {$wallet->id} 100"); + $output = trim(\Artisan::output()); + $this->assertSame(0, $code); + $this->assertSame("", $output); + $wallet->refresh(); + $this->assertSame(100, $wallet->balance); + + // Add debit with a transaction description + // Note: The double-dash trick to make it working with a negative number input + $code = \Artisan::call("wallet:add-transaction --message=debit -- {$wallet->id} -100"); + $output = trim(\Artisan::output()); + $this->assertSame(0, $code); + $this->assertSame("", $output); + $wallet->refresh(); + $this->assertSame(0, $wallet->balance); + $this->assertCount(1, $wallet->transactions()->where('description', 'debit')->get()); + } +} diff --git a/src/tests/Feature/Console/Wallet/BalancesTest.php b/src/tests/Feature/Console/Wallet/BalancesTest.php new file mode 100644 index 00000000..6f3bb5e4 --- /dev/null +++ b/src/tests/Feature/Console/Wallet/BalancesTest.php @@ -0,0 +1,70 @@ +deleteTestUser('wallets-controller@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('wallets-controller@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test command run for a specified wallet + */ + public function testHandle(): void + { + Queue::fake(); + + $user = $this->getTestUser('wallets-controller@kolabnow.com'); + $wallet = $user->wallets()->first(); + + // Expect no wallets with balance=0 + $code = \Artisan::call("wallet:balances"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertTrue(strpos($output, $wallet->id) === false); + + $wallet->balance = -100; + $wallet->save(); + + // Expect the wallet with a negative balance in output + $code = \Artisan::call("wallet:balances"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertMatchesRegularExpression( + '|' . preg_quote($wallet->id, '|') . ': {5}-100 \(account: https://.*/admin/accounts/show/' + . $user->id . ' \(' . preg_quote($user->email, '|') . '\)\)|', + $output + ); + + $user->delete(); + + // Expect no wallet with deleted owner in output + $code = \Artisan::call("wallet:balances"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertTrue(strpos($output, $wallet->id) === false); + } +} diff --git a/src/tests/Feature/Console/WalletChargeTest.php b/src/tests/Feature/Console/Wallet/ChargeTest.php similarity index 96% rename from src/tests/Feature/Console/WalletChargeTest.php rename to src/tests/Feature/Console/Wallet/ChargeTest.php index ec681731..628114eb 100644 --- a/src/tests/Feature/Console/WalletChargeTest.php +++ b/src/tests/Feature/Console/Wallet/ChargeTest.php @@ -1,146 +1,147 @@ deleteTestUser('wallet-charge@kolabnow.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('wallet-charge@kolabnow.com'); parent::tearDown(); } /** * Test command run for a specified wallet */ public function testHandleSingle(): void { $user = $this->getTestUser('wallet-charge@kolabnow.com'); $wallet = $user->wallets()->first(); $wallet->balance = 0; $wallet->save(); Queue::fake(); // Non-existing wallet ID $this->artisan('wallet:charge 123') - ->assertExitCode(1); + ->assertExitCode(1) + ->expectsOutput("Wallet not found."); Queue::assertNothingPushed(); // The wallet has no entitlements, expect no charge and no check $this->artisan('wallet:charge ' . $wallet->id) ->assertExitCode(0); Queue::assertNothingPushed(); // The wallet has no entitlements, but has negative balance $wallet->balance = -100; $wallet->save(); $this->artisan('wallet:charge ' . $wallet->id) ->assertExitCode(0); Queue::assertPushed(\App\Jobs\WalletCharge::class, 0); Queue::assertPushed(\App\Jobs\WalletCheck::class, 1); Queue::assertPushed(\App\Jobs\WalletCheck::class, function ($job) use ($wallet) { $job_wallet = TestCase::getObjectProperty($job, 'wallet'); return $job_wallet->id === $wallet->id; }); Queue::fake(); // The wallet has entitlements to charge, and negative balance $sku = \App\Sku::where('title', 'mailbox')->first(); $entitlement = \App\Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, 'cost' => 100, 'entitleable_id' => $user->id, 'entitleable_type' => \App\User::class, ]); \App\Entitlement::where('id', $entitlement->id)->update([ 'created_at' => \Carbon\Carbon::now()->subMonths(1), 'updated_at' => \Carbon\Carbon::now()->subMonths(1), ]); \App\User::where('id', $user->id)->update([ 'created_at' => \Carbon\Carbon::now()->subMonths(1), 'updated_at' => \Carbon\Carbon::now()->subMonths(1), ]); $this->assertSame(100, $wallet->fresh()->chargeEntitlements(false)); $this->artisan('wallet:charge ' . $wallet->id) ->assertExitCode(0); Queue::assertPushed(\App\Jobs\WalletCharge::class, 1); Queue::assertPushed(\App\Jobs\WalletCharge::class, function ($job) use ($wallet) { $job_wallet = TestCase::getObjectProperty($job, 'wallet'); return $job_wallet->id === $wallet->id; }); Queue::assertPushed(\App\Jobs\WalletCheck::class, 1); Queue::assertPushed(\App\Jobs\WalletCheck::class, function ($job) use ($wallet) { $job_wallet = TestCase::getObjectProperty($job, 'wallet'); return $job_wallet->id === $wallet->id; }); } /** * Test command run for all wallets */ public function testHandleAll(): void { $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->balance = 0; $wallet->save(); // backdate john's entitlements and set balance=0 for all wallets $this->backdateEntitlements($user->entitlements, \Carbon\Carbon::now()->subWeeks(5)); \App\Wallet::where('balance', '<', '0')->update(['balance' => 0]); $user2 = $this->getTestUser('wallet-charge@kolabnow.com'); $wallet2 = $user2->wallets()->first(); $wallet2->balance = -100; $wallet2->save(); Queue::fake(); // Non-existing wallet ID $this->artisan('wallet:charge')->assertExitCode(0); Queue::assertPushed(\App\Jobs\WalletCheck::class, 2); Queue::assertPushed(\App\Jobs\WalletCheck::class, function ($job) use ($wallet) { $job_wallet = TestCase::getObjectProperty($job, 'wallet'); return $job_wallet->id === $wallet->id; }); Queue::assertPushed(\App\Jobs\WalletCheck::class, function ($job) use ($wallet2) { $job_wallet = TestCase::getObjectProperty($job, 'wallet'); return $job_wallet->id === $wallet2->id; }); Queue::assertPushed(\App\Jobs\WalletCharge::class, 1); Queue::assertPushed(\App\Jobs\WalletCharge::class, function ($job) use ($wallet) { $job_wallet = TestCase::getObjectProperty($job, 'wallet'); return $job_wallet->id === $wallet->id; }); } } diff --git a/src/tests/Feature/Console/Wallet/ExpectedTest.php b/src/tests/Feature/Console/Wallet/ExpectedTest.php new file mode 100644 index 00000000..ebbdba6f --- /dev/null +++ b/src/tests/Feature/Console/Wallet/ExpectedTest.php @@ -0,0 +1,74 @@ +deleteTestUser('wallets-controller@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('wallets-controller@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test command run for a specified wallet + */ + public function testHandle(): void + { + Queue::fake(); + + $user = $this->getTestUser('wallets-controller@kolabnow.com'); + $wallet = $user->wallets()->first(); + + // Non-existing user + $code = \Artisan::call("wallet:expected --user=123"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("User not found.", $output); + + // Expected charges for a specified user + $code = \Artisan::call("wallet:expected --user={$user->id}"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertMatchesRegularExpression( + "|expect charging wallet {$wallet->id} for user {$user->email} with 0|", + $output + ); + + // Test --non-zero argument + $code = \Artisan::call("wallet:expected --user={$user->id} --non-zero"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertTrue(strpos($output, $wallet->id) === false); + + // Expected charges for all wallets + $code = \Artisan::call("wallet:expected"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertMatchesRegularExpression( + "|expect charging wallet {$wallet->id} for user {$user->email} with 0|", + $output + ); + } +} diff --git a/src/tests/Feature/Console/Wallet/GetBalanceTest.php b/src/tests/Feature/Console/Wallet/GetBalanceTest.php new file mode 100644 index 00000000..da8197ea --- /dev/null +++ b/src/tests/Feature/Console/Wallet/GetBalanceTest.php @@ -0,0 +1,57 @@ +deleteTestUser('wallets-controller@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('wallets-controller@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test command run for a specified wallet + */ + public function testHandle(): void + { + Queue::fake(); + + $user = $this->getTestUser('wallets-controller@kolabnow.com'); + $wallet = $user->wallets()->first(); + + // Non-existing wallet + $code = \Artisan::call("wallet:get-balance 123"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("Wallet not found.", $output); + + $wallet->balance = -100; + $wallet->save(); + + // Existing wallet + $code = \Artisan::call("wallet:get-balance {$wallet->id}"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertSame('-100', $output); + } +} diff --git a/src/tests/Feature/Console/Wallet/GetDiscountTest.php b/src/tests/Feature/Console/Wallet/GetDiscountTest.php new file mode 100644 index 00000000..a5ac029a --- /dev/null +++ b/src/tests/Feature/Console/Wallet/GetDiscountTest.php @@ -0,0 +1,65 @@ +deleteTestUser('wallets-controller@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('wallets-controller@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test command run for a specified wallet + */ + public function testHandle(): void + { + Queue::fake(); + + $user = $this->getTestUser('wallets-controller@kolabnow.com'); + $wallet = $user->wallets()->first(); + + // Non-existing wallet + $code = \Artisan::call("wallet:get-discount 123"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("Wallet not found.", $output); + + // No discount + $code = \Artisan::call("wallet:get-discount {$wallet->id}"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertSame("No discount on this wallet.", $output); + + $discount = \App\Discount::withObjectTenantContext($user)->where('discount', 100)->first(); + $wallet->discount()->associate($discount); + $wallet->save(); + + // With discount + $code = \Artisan::call("wallet:get-discount {$wallet->id}"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertSame("100", $output); + } +} diff --git a/src/tests/Feature/Console/Wallet/MandateTest.php b/src/tests/Feature/Console/Wallet/MandateTest.php new file mode 100644 index 00000000..dca2449f --- /dev/null +++ b/src/tests/Feature/Console/Wallet/MandateTest.php @@ -0,0 +1,57 @@ +deleteTestUser('wallets-controller@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('wallets-controller@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test command run for a specified wallet + */ + public function testHandle(): void + { + Queue::fake(); + + $user = $this->getTestUser('wallets-controller@kolabnow.com'); + $wallet = $user->wallets()->first(); + + // Non-existing wallet + $code = \Artisan::call("wallet:mandate 123"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("Wallet not found.", $output); + + // No mandate + $code = \Artisan::call("wallet:mandate {$wallet->id}"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertSame("Auto-payment: none", $output); + + // TODO: Test an existing mandate + $this->markTestIncomplete(); + } +} diff --git a/src/tests/Feature/Console/Wallet/SetBalanceTest.php b/src/tests/Feature/Console/Wallet/SetBalanceTest.php new file mode 100644 index 00000000..e3b7af08 --- /dev/null +++ b/src/tests/Feature/Console/Wallet/SetBalanceTest.php @@ -0,0 +1,56 @@ +deleteTestUser('wallets-controller@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('wallets-controller@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test command run for a specified wallet + */ + public function testHandle(): void + { + Queue::fake(); + + $user = $this->getTestUser('wallets-controller@kolabnow.com'); + $wallet = $user->wallets()->first(); + + // Non-existing wallet + $code = \Artisan::call("wallet:set-balance 123 123"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("Wallet not found.", $output); + + // Existing wallet + // Note: The double-dash trick to make it working with a negative number input + $code = \Artisan::call("wallet:set-balance -- {$wallet->id} -123"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertSame('', $output); + $this->assertSame(-123, $wallet->fresh()->balance); + } +} diff --git a/src/tests/Feature/Console/Wallet/SetDiscountTest.php b/src/tests/Feature/Console/Wallet/SetDiscountTest.php new file mode 100644 index 00000000..ef554f3d --- /dev/null +++ b/src/tests/Feature/Console/Wallet/SetDiscountTest.php @@ -0,0 +1,68 @@ +deleteTestUser('wallets-controller@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('wallets-controller@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test command run for a specified wallet + */ + public function testHandle(): void + { + $user = $this->getTestUser('wallets-controller@kolabnow.com'); + $package = \App\Package::where('title', 'kolab')->first(); + $user->assignPackage($package); + $wallet = $user->wallets()->first(); + $discount = \App\Discount::withObjectTenantContext($user)->where('discount', 100)->first(); + + // Invalid wallet id + $code = \Artisan::call("wallet:set-discount 123 123"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("Wallet not found.", $output); + + // Invalid discount id + $code = \Artisan::call("wallet:set-discount {$wallet->id} 123"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("Discount not found.", $output); + + // Assign a discount + $code = \Artisan::call("wallet:set-discount {$wallet->id} {$discount->id}"); + $output = trim(\Artisan::output()); + $this->assertSame(0, $code); + $this->assertSame("", $output); + $wallet->refresh(); + $this->assertSame($discount->id, $wallet->discount_id); + + // Remove the discount + $code = \Artisan::call("wallet:set-discount {$wallet->id} 0"); + $output = trim(\Artisan::output()); + $this->assertSame(0, $code); + $this->assertSame("", $output); + $wallet->refresh(); + $this->assertNull($wallet->discount_id); + } +} diff --git a/src/tests/Feature/Console/WalletDiscountTest.php b/src/tests/Feature/Console/Wallet/SettingsTest.php similarity index 61% copy from src/tests/Feature/Console/WalletDiscountTest.php copy to src/tests/Feature/Console/Wallet/SettingsTest.php index bb87edfc..7dfb608d 100644 --- a/src/tests/Feature/Console/WalletDiscountTest.php +++ b/src/tests/Feature/Console/Wallet/SettingsTest.php @@ -1,13 +1,13 @@ markTestIncomplete(); } } diff --git a/src/tests/Feature/Console/WalletDiscountTest.php b/src/tests/Feature/Console/Wallet/TransactionsTest.php similarity index 60% copy from src/tests/Feature/Console/WalletDiscountTest.php copy to src/tests/Feature/Console/Wallet/TransactionsTest.php index bb87edfc..a837ed9d 100644 --- a/src/tests/Feature/Console/WalletDiscountTest.php +++ b/src/tests/Feature/Console/Wallet/TransactionsTest.php @@ -1,13 +1,13 @@ markTestIncomplete(); } } diff --git a/src/tests/Feature/Console/Wallet/UntilTest.php b/src/tests/Feature/Console/Wallet/UntilTest.php new file mode 100644 index 00000000..b948d3fc --- /dev/null +++ b/src/tests/Feature/Console/Wallet/UntilTest.php @@ -0,0 +1,68 @@ +deleteTestUser('wallets-controller@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('wallets-controller@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test command run for a specified wallet + */ + public function testHandle(): void + { + Queue::fake(); + + $user = $this->getTestUser('wallets-controller@kolabnow.com'); + $wallet = $user->wallets()->first(); + + // Non-existing wallet + $code = \Artisan::call("wallet:until 123"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("Wallet not found.", $output); + + // Existing wallet + $code = \Artisan::call("wallet:until {$wallet->id}"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertSame("Lasts until: unknown", $output); + + $package = \App\Package::withObjectTenantContext($user)->where('title', 'kolab')->first(); + $user->assignPackage($package); + $wallet->balance = 1000; + $wallet->save(); + + $expected = \now()->addMonths(2)->toDateString(); + + // Existing wallet + $code = \Artisan::call("wallet:until {$wallet->id}"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertSame("Lasts until: $expected", $output); + } +} diff --git a/src/tests/Feature/Console/WalletDiscountTest.php b/src/tests/Feature/Console/WalletsTest.php similarity index 78% rename from src/tests/Feature/Console/WalletDiscountTest.php rename to src/tests/Feature/Console/WalletsTest.php index bb87edfc..7348fc25 100644 --- a/src/tests/Feature/Console/WalletDiscountTest.php +++ b/src/tests/Feature/Console/WalletsTest.php @@ -1,13 +1,13 @@ markTestIncomplete(); } } diff --git a/src/tests/Feature/Controller/WalletsTest.php b/src/tests/Feature/Controller/WalletsTest.php index b03f84e3..72785b3e 100644 --- a/src/tests/Feature/Controller/WalletsTest.php +++ b/src/tests/Feature/Controller/WalletsTest.php @@ -1,356 +1,356 @@ deleteTestUser('wallets-controller@kolabnow.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('wallets-controller@kolabnow.com'); parent::tearDown(); } /** * Test for getWalletNotice() method */ public function testGetWalletNotice(): void { $user = $this->getTestUser('wallets-controller@kolabnow.com'); - $package = \App\Package::where('title', 'kolab')->first(); + $package = \App\Package::withObjectTenantContext($user)->where('title', 'kolab')->first(); $user->assignPackage($package); $wallet = $user->wallets()->first(); $controller = new WalletsController(); $method = new \ReflectionMethod($controller, 'getWalletNotice'); $method->setAccessible(true); // User/entitlements created today, balance=0 $notice = $method->invoke($controller, $wallet); $this->assertSame('You are in your free trial period.', $notice); $wallet->owner->created_at = Carbon::now()->subDays(15); $wallet->owner->save(); $notice = $method->invoke($controller, $wallet); $this->assertSame('Your free trial is about to end, top up to continue.', $notice); // User/entitlements created today, balance=-10 CHF $wallet->balance = -1000; $notice = $method->invoke($controller, $wallet); $this->assertSame('You are out of credit, top up your balance now.', $notice); // User/entitlements created slightly more than a month ago, balance=9,99 CHF (monthly) $wallet->owner->created_at = Carbon::now()->subMonthsWithoutOverflow(1)->subDays(1); $wallet->owner->save(); // test "1 month" $wallet->balance = 990; $notice = $method->invoke($controller, $wallet); $this->assertMatchesRegularExpression('/\((1 month|4 weeks)\)/', $notice); // test "2 months" $wallet->balance = 990 * 2.6; $notice = $method->invoke($controller, $wallet); $this->assertMatchesRegularExpression('/\(2 months 2 weeks\)/', $notice); // Change locale to make sure the text is localized by Carbon \app()->setLocale('de'); // test "almost 2 years" $wallet->balance = 990 * 23.5; $notice = $method->invoke($controller, $wallet); $this->assertMatchesRegularExpression('/\(1 Jahr 11 Monate\)/', $notice); // Old entitlements, 100% discount $this->backdateEntitlements($wallet->entitlements, Carbon::now()->subDays(40)); - $discount = \App\Discount::where('discount', 100)->first(); + $discount = \App\Discount::withObjectTenantContext($user)->where('discount', 100)->first(); $wallet->discount()->associate($discount); $notice = $method->invoke($controller, $wallet->refresh()); $this->assertSame(null, $notice); } /** * Test fetching pdf receipt */ public function testReceiptDownload(): void { $user = $this->getTestUser('wallets-controller@kolabnow.com'); $john = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); // Unauth access not allowed $response = $this->get("api/v4/wallets/{$wallet->id}/receipts/2020-05"); $response->assertStatus(401); $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/receipts/2020-05"); $response->assertStatus(403); // Invalid receipt id (current month) $receiptId = date('Y-m'); $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}"); $response->assertStatus(404); // Invalid receipt id $receiptId = '1000-03'; $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}"); $response->assertStatus(404); // Valid receipt id $year = intval(date('Y')) - 1; $receiptId = "$year-12"; $filename = \config('app.name') . " Receipt for $year-12"; $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}"); $response->assertStatus(200); $response->assertHeader('content-type', 'application/pdf'); $response->assertHeader('content-disposition', 'attachment; filename="' . $filename . '"'); $response->assertHeader('content-length'); $length = $response->headers->get('content-length'); $content = $response->content(); $this->assertStringStartsWith("%PDF-1.", $content); $this->assertEquals(strlen($content), $length); } /** * Test fetching list of receipts */ public function testReceipts(): void { $user = $this->getTestUser('wallets-controller@kolabnow.com'); $john = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->payments()->delete(); // Unauth access not allowed $response = $this->get("api/v4/wallets/{$wallet->id}/receipts"); $response->assertStatus(401); $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/receipts"); $response->assertStatus(403); // Empty list expected $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame([], $json['list']); $this->assertSame(1, $json['page']); $this->assertSame(0, $json['count']); $this->assertSame(false, $json['hasMore']); // Insert a payment to the database $date = Carbon::create(intval(date('Y')) - 1, 4, 30); $payment = Payment::create([ 'id' => 'AAA1', 'status' => PaymentProvider::STATUS_PAID, 'type' => PaymentProvider::TYPE_ONEOFF, 'description' => 'Paid in April', 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 1111, 'currency' => 'CHF', 'currency_amount' => 1111, ]); $payment->updated_at = $date; $payment->save(); $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame([$date->format('Y-m')], $json['list']); $this->assertSame(1, $json['page']); $this->assertSame(1, $json['count']); $this->assertSame(false, $json['hasMore']); } /** * Test fetching a wallet (GET /api/v4/wallets/:id) */ public function testShow(): void { $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $wallet = $john->wallets()->first(); $wallet->balance = -100; $wallet->save(); // Accessing a wallet of someone else $response = $this->actingAs($jack)->get("api/v4/wallets/{$wallet->id}"); $response->assertStatus(403); // Accessing non-existing wallet $response = $this->actingAs($jack)->get("api/v4/wallets/aaa"); $response->assertStatus(404); // Wallet owner $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame($wallet->id, $json['id']); $this->assertSame('CHF', $json['currency']); $this->assertSame($wallet->balance, $json['balance']); $this->assertTrue(empty($json['description'])); $this->assertTrue(!empty($json['notice'])); } /** * Test fetching wallet transactions */ public function testTransactions(): void { $package_kolab = \App\Package::where('title', 'kolab')->first(); $user = $this->getTestUser('wallets-controller@kolabnow.com'); $user->assignPackage($package_kolab); $john = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); // Unauth access not allowed $response = $this->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(401); $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(403); // Expect empty list $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame([], $json['list']); $this->assertSame(1, $json['page']); $this->assertSame(0, $json['count']); $this->assertSame(false, $json['hasMore']); // Create some sample transactions $transactions = $this->createTestTransactions($wallet); $transactions = array_reverse($transactions); $pages = array_chunk($transactions, 10 /* page size*/); // Get the first page $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(1, $json['page']); $this->assertSame(10, $json['count']); $this->assertSame(true, $json['hasMore']); $this->assertCount(10, $json['list']); foreach ($pages[0] as $idx => $transaction) { $this->assertSame($transaction->id, $json['list'][$idx]['id']); $this->assertSame($transaction->type, $json['list'][$idx]['type']); $this->assertSame(\config('app.currency'), $json['list'][$idx]['currency']); $this->assertSame($transaction->shortDescription(), $json['list'][$idx]['description']); $this->assertFalse($json['list'][$idx]['hasDetails']); $this->assertFalse(array_key_exists('user', $json['list'][$idx])); } $search = null; // Get the second page $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?page=2"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(2, $json['page']); $this->assertSame(2, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertCount(2, $json['list']); foreach ($pages[1] as $idx => $transaction) { $this->assertSame($transaction->id, $json['list'][$idx]['id']); $this->assertSame($transaction->type, $json['list'][$idx]['type']); $this->assertSame($transaction->shortDescription(), $json['list'][$idx]['description']); $this->assertSame( $transaction->type == Transaction::WALLET_DEBIT, $json['list'][$idx]['hasDetails'] ); $this->assertFalse(array_key_exists('user', $json['list'][$idx])); if ($transaction->type == Transaction::WALLET_DEBIT) { $search = $transaction->id; } } // Get a non-existing page $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?page=3"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(3, $json['page']); $this->assertSame(0, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertCount(0, $json['list']); // Sub-transaction searching $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?transaction=123"); $response->assertStatus(404); $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?transaction={$search}"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(1, $json['page']); $this->assertSame(2, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertCount(2, $json['list']); $this->assertSame(Transaction::ENTITLEMENT_BILLED, $json['list'][0]['type']); $this->assertSame(Transaction::ENTITLEMENT_BILLED, $json['list'][1]['type']); // Test that John gets 404 if he tries to access // someone else's transaction ID on his wallet's endpoint $wallet = $john->wallets()->first(); $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/transactions?transaction={$search}"); $response->assertStatus(404); } }