diff --git a/src/app/Jobs/WalletCheck.php b/src/app/Jobs/WalletCheck.php index 0eb7f41d..274240ca 100644 --- a/src/app/Jobs/WalletCheck.php +++ b/src/app/Jobs/WalletCheck.php @@ -1,311 +1,338 @@ wallet = $wallet; } /** * Execute the job. * - * @return void + * @return ?string Executed action (THRESHOLD_*) */ public function handle() { if ($this->wallet->balance >= 0) { - return; + return null; } $now = Carbon::now(); // Delete the account if (self::threshold($this->wallet, self::THRESHOLD_DELETE) < $now) { $this->deleteAccount(); - return; + return self::THRESHOLD_DELETE; } // Warn about the upcomming account deletion if (self::threshold($this->wallet, self::THRESHOLD_BEFORE_DELETE) < $now) { $this->warnBeforeDelete(); - return; + return self::THRESHOLD_BEFORE_DELETE; } // Suspend the account if (self::threshold($this->wallet, self::THRESHOLD_SUSPEND) < $now) { $this->suspendAccount(); - return; + return self::THRESHOLD_SUSPEND; + } + + // Try to top-up the wallet before suspending the account + if (self::threshold($this->wallet, self::THRESHOLD_BEFORE_SUSPEND) < $now) { + PaymentsController::topUpWallet($this->wallet); + return self::THRESHOLD_BEFORE_SUSPEND; } // Send the second reminder if (self::threshold($this->wallet, self::THRESHOLD_REMINDER) < $now) { $this->secondReminder(); - return; + return self::THRESHOLD_REMINDER; + } + + // Try to top-up the wallet before the second reminder + if (self::threshold($this->wallet, self::THRESHOLD_BEFORE_REMINDER) < $now) { + PaymentsController::topUpWallet($this->wallet); + return self::THRESHOLD_BEFORE_REMINDER; } // Send the initial reminder if (self::threshold($this->wallet, self::THRESHOLD_INITIAL) < $now) { $this->initialReminder(); - return; + return self::THRESHOLD_INITIAL; } + + return null; } /** * Send the initial reminder */ protected function initialReminder() { if ($this->wallet->getSetting('balance_warning_initial')) { return; } // TODO: Should we check if the account is already suspended? $label = "Notification sent for"; $this->sendMail(\App\Mail\NegativeBalance::class, false, $label); $now = \Carbon\Carbon::now()->toDateTimeString(); $this->wallet->setSetting('balance_warning_initial', $now); } /** * Send the second reminder */ protected function secondReminder() { if ($this->wallet->getSetting('balance_warning_reminder')) { return; } // TODO: Should we check if the account is already suspended? $label = "Reminder sent for"; $this->sendMail(\App\Mail\NegativeBalanceReminder::class, false, $label); $now = \Carbon\Carbon::now()->toDateTimeString(); $this->wallet->setSetting('balance_warning_reminder', $now); } /** * Suspend the account (and send the warning) */ protected function suspendAccount() { if ($this->wallet->getSetting('balance_warning_suspended')) { return; } // Sanity check, already deleted if (!$this->wallet->owner) { return; } // Suspend the account $this->wallet->owner->suspend(); foreach ($this->wallet->entitlements as $entitlement) { if ( $entitlement->entitleable_type == \App\Domain::class || $entitlement->entitleable_type == \App\User::class ) { $entitlement->entitleable->suspend(); } } $label = "Account suspended"; $this->sendMail(\App\Mail\NegativeBalanceSuspended::class, false, $label); $now = \Carbon\Carbon::now()->toDateTimeString(); $this->wallet->setSetting('balance_warning_suspended', $now); } /** * Send the last warning before delete */ protected function warnBeforeDelete() { if ($this->wallet->getSetting('balance_warning_before_delete')) { return; } // Sanity check, already deleted if (!$this->wallet->owner) { return; } $label = "Last warning sent for"; $this->sendMail(\App\Mail\NegativeBalanceBeforeDelete::class, true, $label); $now = \Carbon\Carbon::now()->toDateTimeString(); $this->wallet->setSetting('balance_warning_before_delete', $now); } /** * Delete the account */ protected function deleteAccount() { // TODO: This will not work when we actually allow multiple-wallets per account // but in this case we anyway have to change the whole thing // and calculate summarized balance from all wallets. // The dirty work will be done by UserObserver if ($this->wallet->owner) { $email = $this->wallet->owner->email; $this->wallet->owner->delete(); \Log::info( sprintf( "[WalletCheck] Account deleted %s (%s)", $this->wallet->id, $email ) ); } } /** * Send the email * * @param string $class Mailable class name * @param bool $with_external Use users's external email * @param ?string $log_label Log label */ protected function sendMail($class, $with_external = false, $log_label = null): void { // TODO: Send the email to all wallet controllers? $mail = new $class($this->wallet, $this->wallet->owner); list($to, $cc) = \App\Mail\Helper::userEmails($this->wallet->owner, $with_external); if (!empty($to) || !empty($cc)) { try { Mail::to($to)->cc($cc)->send($mail); if ($log_label) { $msg = sprintf( "[WalletCheck] %s %s (%s)", $log_label, $this->wallet->id, empty($cc) ? $to : implode(', ', array_merge([$to], $cc)), ); \Log::info($msg); } } catch (\Exception $e) { $msg = sprintf( "[WalletCheck] Failed to send mail for %s (%s): %s", $this->wallet->id, empty($cc) ? $to : implode(', ', array_merge([$to], $cc)), $e->getMessage() ); \Log::error($msg); throw $e; } } } /** * Get the date-time for an action threshold. Calculated using * the date when a wallet balance turned negative. * * @param \App\Wallet $wallet A wallet * @param string $type Action type (one of self::THRESHOLD_*) * * @return \Carbon\Carbon The threshold date-time object */ public static function threshold(Wallet $wallet, string $type): ?Carbon { $negative_since = $wallet->getSetting('balance_negative_since'); // Migration scenario: balance<0, but no balance_negative_since set if (!$negative_since) { // 2h back from now, so first run can sent the initial notification $negative_since = Carbon::now()->subHours(2); $wallet->setSetting('balance_negative_since', $negative_since->toDateTimeString()); } else { $negative_since = new Carbon($negative_since); } $remind = 7; // remind after first X days $suspend = 14; // suspend after next X days $delete = 21; // delete after next X days $warn = 3; // warn about delete on X days before delete // Acount deletion if ($type == self::THRESHOLD_DELETE) { return $negative_since->addDays($delete + $suspend + $remind); } // Warning about the upcomming account deletion if ($type == self::THRESHOLD_BEFORE_DELETE) { return $negative_since->addDays($delete + $suspend + $remind - $warn); } // Account suspension if ($type == self::THRESHOLD_SUSPEND) { return $negative_since->addDays($suspend + $remind); } + // A day before account suspension + if ($type == self::THRESHOLD_BEFORE_SUSPEND) { + return $negative_since->addDays($suspend + $remind - 1); + } + // Second notification if ($type == self::THRESHOLD_REMINDER) { return $negative_since->addDays($remind); } + // A day before the second reminder + if ($type == self::THRESHOLD_BEFORE_REMINDER) { + return $negative_since->addDays($remind - 1); + } + // Initial notification // Give it an hour so the async recurring payment has a chance to be finished if ($type == self::THRESHOLD_INITIAL) { return $negative_since->addHours(1); } return null; } } diff --git a/src/tests/Feature/Jobs/WalletCheckTest.php b/src/tests/Feature/Jobs/WalletCheckTest.php index 9dbfb406..72faaa5e 100644 --- a/src/tests/Feature/Jobs/WalletCheckTest.php +++ b/src/tests/Feature/Jobs/WalletCheckTest.php @@ -1,275 +1,328 @@ getTestUser('ned@kolab.org'); if ($ned->isSuspended()) { $ned->status -= User::STATUS_SUSPENDED; $ned->save(); } $this->deleteTestUser('wallet-check@kolabnow.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $ned = $this->getTestUser('ned@kolab.org'); if ($ned->isSuspended()) { $ned->status -= User::STATUS_SUSPENDED; $ned->save(); } $this->deleteTestUser('wallet-check@kolabnow.com'); parent::tearDown(); } /** * Test job handle, initial negative-balance notification */ public function testHandleInitial(): void { Mail::fake(); $user = $this->getTestUser('ned@kolab.org'); $user->setSetting('external_email', 'external@test.com'); $wallet = $user->wallets()->first(); $now = Carbon::now(); // Balance is not negative, double-update+save for proper resetting of the state $wallet->balance = -100; $wallet->save(); $wallet->balance = 0; $wallet->save(); $job = new WalletCheck($wallet); $job->handle(); Mail::assertNothingSent(); // Balance is negative now $wallet->balance = -100; $wallet->save(); $job = new WalletCheck($wallet); $job->handle(); Mail::assertNothingSent(); // Balance turned negative 2 hours ago, expect mail sent $wallet->setSetting('balance_negative_since', $now->subHours(2)->toDateTimeString()); $wallet->setSetting('balance_warning_initial', null); $job = new WalletCheck($wallet); $job->handle(); // Assert the mail was sent to the user's email, but not to his external email Mail::assertSent(\App\Mail\NegativeBalance::class, 1); Mail::assertSent(\App\Mail\NegativeBalance::class, function ($mail) use ($user) { return $mail->hasTo($user->email) && !$mail->hasCc('external@test.com'); }); // Run the job again to make sure the notification is not sent again Mail::fake(); $job = new WalletCheck($wallet); $job->handle(); Mail::assertNothingSent(); // Test the migration scenario where a negative wallet has no balance_negative_since set yet Mail::fake(); $wallet->setSetting('balance_negative_since', null); $wallet->setSetting('balance_warning_initial', null); $job = new WalletCheck($wallet); $job->handle(); // Assert the mail was sent to the user's email, but not to his external email Mail::assertSent(\App\Mail\NegativeBalance::class, 1); Mail::assertSent(\App\Mail\NegativeBalance::class, function ($mail) use ($user) { return $mail->hasTo($user->email) && !$mail->hasCc('external@test.com'); }); $wallet->refresh(); $today_regexp = '/' . Carbon::now()->toDateString() . ' [0-9]{2}:[0-9]{2}:[0-9]{2}/'; $this->assertRegExp($today_regexp, $wallet->getSetting('balance_negative_since')); $this->assertRegExp($today_regexp, $wallet->getSetting('balance_warning_initial')); } /** - * Test job handle, reminder notification + * Test job handle, top-up before reminder notification * * @depends testHandleInitial */ + public function testHandleBeforeReminder(): void + { + Mail::fake(); + + $user = $this->getTestUser('ned@kolab.org'); + $wallet = $user->wallets()->first(); + $now = Carbon::now(); + + // Balance turned negative 7-1 days ago + $wallet->setSetting('balance_negative_since', $now->subDays(7 - 1)->toDateTimeString()); + + $job = new WalletCheck($wallet); + $res = $job->handle(); + + Mail::assertNothingSent(); + + // TODO: Test that it actually executed the topUpWallet() + $this->assertSame(WalletCheck::THRESHOLD_BEFORE_REMINDER, $res); + $this->assertFalse($user->fresh()->isSuspended()); + } + + /** + * Test job handle, reminder notification + * + * @depends testHandleBeforeReminder + */ public function testHandleReminder(): void { Mail::fake(); $user = $this->getTestUser('ned@kolab.org'); $user->setSetting('external_email', 'external@test.com'); $wallet = $user->wallets()->first(); $now = Carbon::now(); // Balance turned negative 7+1 days ago, expect mail sent $wallet->setSetting('balance_negative_since', $now->subDays(7 + 1)->toDateTimeString()); $job = new WalletCheck($wallet); $job->handle(); // Assert the mail was sent to the user's email, but not to his external email Mail::assertSent(\App\Mail\NegativeBalanceReminder::class, 1); Mail::assertSent(\App\Mail\NegativeBalanceReminder::class, function ($mail) use ($user) { return $mail->hasTo($user->email) && !$mail->hasCc('external@test.com'); }); // Run the job again to make sure the notification is not sent again Mail::fake(); $job = new WalletCheck($wallet); $job->handle(); Mail::assertNothingSent(); } /** - * Test job handle, account suspending + * Test job handle, top-up wallet before account suspending * * @depends testHandleReminder */ + public function testHandleBeforeSuspended(): void + { + Mail::fake(); + + $user = $this->getTestUser('ned@kolab.org'); + $wallet = $user->wallets()->first(); + $now = Carbon::now(); + + // Balance turned negative 7+14-1 days ago + $days = 7 + 14 - 1; + $wallet->setSetting('balance_negative_since', $now->subDays($days)->toDateTimeString()); + + $job = new WalletCheck($wallet); + $res = $job->handle(); + + Mail::assertNothingSent(); + + // TODO: Test that it actually executed the topUpWallet() + $this->assertSame(WalletCheck::THRESHOLD_BEFORE_SUSPEND, $res); + $this->assertFalse($user->fresh()->isSuspended()); + } + + /** + * Test job handle, account suspending + * + * @depends testHandleBeforeSuspended + */ public function testHandleSuspended(): void { Mail::fake(); $user = $this->getTestUser('ned@kolab.org'); $user->setSetting('external_email', 'external@test.com'); $wallet = $user->wallets()->first(); $now = Carbon::now(); // Balance turned negative 7+14+1 days ago, expect mail sent $days = 7 + 14 + 1; $wallet->setSetting('balance_negative_since', $now->subDays($days)->toDateTimeString()); $job = new WalletCheck($wallet); $job->handle(); // Assert the mail was sent to the user's email, but not to his external email Mail::assertSent(\App\Mail\NegativeBalanceSuspended::class, 1); Mail::assertSent(\App\Mail\NegativeBalanceSuspended::class, function ($mail) use ($user) { return $mail->hasTo($user->email) && !$mail->hasCc('external@test.com'); }); // Check that it has been suspended $this->assertTrue($user->fresh()->isSuspended()); // TODO: Test that group account members/domain are also being suspended /* foreach ($wallet->entitlements()->fresh()->get() as $entitlement) { if ( $entitlement->entitleable_type == \App\Domain::class || $entitlement->entitleable_type == \App\User::class ) { $this->assertTrue($entitlement->entitleable->isSuspended()); } } */ // Run the job again to make sure the notification is not sent again Mail::fake(); $job = new WalletCheck($wallet); $job->handle(); Mail::assertNothingSent(); } /** * Test job handle, final warning before delete * * @depends testHandleSuspended */ public function testHandleBeforeDelete(): void { Mail::fake(); $user = $this->getTestUser('ned@kolab.org'); $user->setSetting('external_email', 'external@test.com'); $wallet = $user->wallets()->first(); $now = Carbon::now(); // Balance turned negative 7+14+21-3+1 days ago, expect mail sent $days = 7 + 14 + 21 - 3 + 1; $wallet->setSetting('balance_negative_since', $now->subDays($days)->toDateTimeString()); $job = new WalletCheck($wallet); $job->handle(); // Assert the mail was sent to the user's email, and his external email Mail::assertSent(\App\Mail\NegativeBalanceBeforeDelete::class, 1); Mail::assertSent(\App\Mail\NegativeBalanceBeforeDelete::class, function ($mail) use ($user) { return $mail->hasTo($user->email) && $mail->hasCc('external@test.com'); }); // Check that it has not been deleted yet $this->assertFalse($user->fresh()->isDeleted()); // Run the job again to make sure the notification is not sent again Mail::fake(); $job = new WalletCheck($wallet); $job->handle(); Mail::assertNothingSent(); } /** * Test job handle, account delete * * @depends testHandleBeforeDelete */ public function testHandleDelete(): void { Mail::fake(); $user = $this->getTestUser('wallet-check@kolabnow.com'); $wallet = $user->wallets()->first(); $wallet->balance = -100; $wallet->save(); $now = Carbon::now(); $package = \App\Package::where('title', 'kolab')->first(); $user->assignPackage($package); $this->assertFalse($user->isDeleted()); $this->assertCount(4, $user->entitlements()->get()); // Balance turned negative 7+14+21+1 days ago, expect mail sent $days = 7 + 14 + 21 + 1; $wallet->setSetting('balance_negative_since', $now->subDays($days)->toDateTimeString()); $job = new WalletCheck($wallet); $job->handle(); Mail::assertNothingSent(); // Check that it has not been deleted $this->assertTrue($user->fresh()->trashed()); $this->assertCount(0, $user->entitlements()->get()); // TODO: Test it deletes all members of the group account } } diff --git a/src/tests/Functional/Methods/DomainTest.php b/src/tests/Functional/Methods/DomainTest.php index 604f6c29..341d55f7 100644 --- a/src/tests/Functional/Methods/DomainTest.php +++ b/src/tests/Functional/Methods/DomainTest.php @@ -1,114 +1,113 @@ domain = $this->getTestDomain( 'test.domain', [ 'status' => \App\Domain::STATUS_CONFIRMED | \App\Domain::STATUS_VERIFIED, 'type' => \App\Domain::TYPE_EXTERNAL ] ); } public function tearDown(): void { $this->deleteTestDomain('test.domain'); parent::tearDown(); } /** * Verify we can suspend an active domain. */ public function testSuspendForActiveDomain() { Queue::fake(); $this->domain->status |= \App\Domain::STATUS_ACTIVE; $this->assertFalse($this->domain->isSuspended()); $this->assertTrue($this->domain->isActive()); $this->domain->suspend(); $this->assertTrue($this->domain->isSuspended()); $this->assertFalse($this->domain->isActive()); } /** * Verify we can unsuspend a suspended domain */ public function testUnsuspendForSuspendedDomain() { Queue::fake(); $this->domain->status |= \App\Domain::STATUS_SUSPENDED; $this->assertTrue($this->domain->isSuspended()); $this->assertFalse($this->domain->isActive()); $this->domain->unsuspend(); $this->assertFalse($this->domain->isSuspended()); $this->assertTrue($this->domain->isActive()); } /** * Verify we can unsuspend a suspended domain that wasn't confirmed */ public function testUnsuspendForSuspendedUnconfirmedDomain() { Queue::fake(); $this->domain->status = \App\Domain::STATUS_NEW | \App\Domain::STATUS_SUSPENDED; $this->assertTrue($this->domain->isNew()); $this->assertTrue($this->domain->isSuspended()); $this->assertFalse($this->domain->isActive()); $this->assertFalse($this->domain->isConfirmed()); $this->assertFalse($this->domain->isVerified()); $this->domain->unsuspend(); $this->assertTrue($this->domain->isNew()); $this->assertFalse($this->domain->isSuspended()); $this->assertFalse($this->domain->isActive()); $this->assertFalse($this->domain->isConfirmed()); $this->assertFalse($this->domain->isVerified()); } /** * Verify we can unsuspend a suspended domain that was verified but not confirmed */ public function testUnsuspendForSuspendedVerifiedUnconfirmedDomain() { Queue::fake(); $this->domain->status = \App\Domain::STATUS_NEW | \App\Domain::STATUS_SUSPENDED | \App\Domain::STATUS_VERIFIED; $this->assertTrue($this->domain->isNew()); $this->assertTrue($this->domain->isSuspended()); $this->assertFalse($this->domain->isActive()); $this->assertFalse($this->domain->isConfirmed()); $this->assertTrue($this->domain->isVerified()); $this->domain->unsuspend(); $this->assertTrue($this->domain->isNew()); $this->assertFalse($this->domain->isSuspended()); $this->assertFalse($this->domain->isActive()); $this->assertFalse($this->domain->isConfirmed()); $this->assertTrue($this->domain->isVerified()); } - }