diff --git a/src/app/Jobs/WalletCheck.php b/src/app/Jobs/WalletCheck.php index 274240ca..f388848b 100644 --- a/src/app/Jobs/WalletCheck.php +++ b/src/app/Jobs/WalletCheck.php @@ -1,338 +1,338 @@ <?php namespace App\Jobs; use App\Http\Controllers\API\V4\PaymentsController; use App\Wallet; use Carbon\Carbon; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Mail; class WalletCheck implements ShouldQueue { use Dispatchable; use InteractsWithQueue; use Queueable; use SerializesModels; public const THRESHOLD_DELETE = 'delete'; public const THRESHOLD_BEFORE_DELETE = 'before_delete'; public const THRESHOLD_SUSPEND = 'suspend'; public const THRESHOLD_BEFORE_SUSPEND = 'before_suspend'; public const THRESHOLD_REMINDER = 'reminder'; public const THRESHOLD_BEFORE_REMINDER = 'before_reminder'; public const THRESHOLD_INITIAL = 'initial'; /** @var int The number of seconds to wait before retrying the job. */ public $retryAfter = 10; /** @var int How many times retry the job if it fails. */ public $tries = 5; /** @var bool Delete the job if the wallet no longer exist. */ public $deleteWhenMissingModels = true; /** @var \App\Wallet A wallet object */ protected $wallet; /** * Create a new job instance. * * @param \App\Wallet $wallet The wallet that has been charged. * * @return void */ public function __construct(Wallet $wallet) { $this->wallet = $wallet; } /** * Execute the job. * * @return ?string Executed action (THRESHOLD_*) */ public function handle() { if ($this->wallet->balance >= 0) { return null; } $now = Carbon::now(); // Delete the account if (self::threshold($this->wallet, self::THRESHOLD_DELETE) < $now) { $this->deleteAccount(); return self::THRESHOLD_DELETE; } // Warn about the upcomming account deletion if (self::threshold($this->wallet, self::THRESHOLD_BEFORE_DELETE) < $now) { $this->warnBeforeDelete(); return self::THRESHOLD_BEFORE_DELETE; } // Suspend the account if (self::threshold($this->wallet, self::THRESHOLD_SUSPEND) < $now) { $this->suspendAccount(); 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 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 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); + $this->sendMail(\App\Mail\NegativeBalanceSuspended::class, true, $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 72faaa5e..a58778b3 100644 --- a/src/tests/Feature/Jobs/WalletCheckTest.php +++ b/src/tests/Feature/Jobs/WalletCheckTest.php @@ -1,328 +1,328 @@ <?php namespace Tests\Feature\Jobs; use App\Jobs\WalletCheck; use App\User; use Carbon\Carbon; use Illuminate\Support\Facades\Mail; use Tests\TestCase; class WalletCheckTest extends TestCase { /** * {@inheritDoc} */ public function setUp(): void { parent::setUp(); $ned = $this->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, 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, 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'); + 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 } }