diff --git a/bin/phpunit b/bin/phpunit --- a/bin/phpunit +++ b/bin/phpunit @@ -6,6 +6,7 @@ php -dzend_extension=xdebug.so \ vendor/bin/phpunit \ + --no-coverage \ --stop-on-defect \ --stop-on-error \ --stop-on-failure $* diff --git a/src/app/Console/Commands/Job/WalletCheck.php b/src/app/Console/Commands/Job/WalletCheck.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/Job/WalletCheck.php @@ -0,0 +1,40 @@ +argument('wallet')); + + if (!$wallet) { + return 1; + } + + $job = new \App\Jobs\WalletCheck($wallet); + $job->handle(); + } +} diff --git a/src/app/Console/Commands/WalletCharge.php b/src/app/Console/Commands/WalletCharge.php --- a/src/app/Console/Commands/WalletCharge.php +++ b/src/app/Console/Commands/WalletCharge.php @@ -37,7 +37,10 @@ */ public function handle() { - $wallets = \App\Wallet::all(); + // Get all wallets, excluding deleted accounts + $wallets = \App\Wallet::join('users', 'users.id', '=', 'wallets.user_id') + ->whereNull('users.deleted_at') + ->get(); foreach ($wallets as $wallet) { $charge = $wallet->chargeEntitlements(); @@ -50,6 +53,11 @@ // 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/Jobs/UserVerify.php b/src/app/Jobs/UserVerify.php --- a/src/app/Jobs/UserVerify.php +++ b/src/app/Jobs/UserVerify.php @@ -44,22 +44,7 @@ */ public function handle() { - // Verify a mailbox sku is among the user entitlements. - $skuMailbox = \App\Sku::where('title', 'mailbox')->first(); - - if (!$skuMailbox) { - return; - } - - $mailbox = \App\Entitlement::where( - [ - 'sku_id' => $skuMailbox->id, - 'entitleable_id' => $this->user->id, - 'entitleable_type' => User::class - ] - )->first(); - - if (!$mailbox) { + if (!$this->user->hasSku('mailbox')) { return; } diff --git a/src/app/Jobs/WalletCheck.php b/src/app/Jobs/WalletCheck.php new file mode 100644 --- /dev/null +++ b/src/app/Jobs/WalletCheck.php @@ -0,0 +1,277 @@ +wallet = $wallet; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + if ($this->wallet->balance >= 0) { + return; + } + + $now = Carbon::now(); + + // Delete the account + if (self::threshold($this->wallet, self::THRESHOLD_DELETE) < $now) { + $this->deleteAccount(); + return; + } + + // Warn about the upcomming account deletion + if (self::threshold($this->wallet, self::THRESHOLD_BEFORE_DELETE) < $now) { + $this->warnBeforeDelete(); + return; + } + + // Suspend the account + if (self::threshold($this->wallet, self::THRESHOLD_SUSPEND) < $now) { + $this->suspendAccount(); + return; + } + + // Send the second reminder + if (self::threshold($this->wallet, self::THRESHOLD_REMINDER) < $now) { + $this->secondReminder(); + return; + } + + // Send the initial reminder + if (self::threshold($this->wallet, self::THRESHOLD_INITIAL) < $now) { + $this->initialReminder(); + return; + } + } + + /** + * 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? + + $this->sendMail(\App\Mail\NegativeBalance::class); + + $now = \Carbon\Carbon::now()->toDateTimeString(); + $this->wallet->setSetting('balance_warning_initial', $now); + + \Log::info("[WalletCheck] Notification sent for {$this->wallet->owner->email}"); + } + + /** + * 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? + + $this->sendMail(\App\Mail\NegativeBalanceReminder::class); + + $now = \Carbon\Carbon::now()->toDateTimeString(); + $this->wallet->setSetting('balance_warning_reminder', $now); + + \Log::info("[WalletCheck] Reminder sent for {$this->wallet->owner->email}"); + } + + /** + * 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; + } + + \Log::info("[WalletCheck] Suspend account {$this->wallet->owner->email}"); + + // 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(); + } + } + + $this->sendMail(\App\Mail\NegativeBalanceSuspended::class); + + $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; + } + + $this->sendMail(\App\Mail\NegativeBalanceBeforeDelete::class, true); + + $now = \Carbon\Carbon::now()->toDateTimeString(); + $this->wallet->setSetting('balance_warning_before_delete', $now); + + \Log::info("[WalletCheck] Last warning sent for {$this->wallet->owner->email}"); + } + + /** + * 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) { + \Log::info("[WalletCheck] Delete account {$this->wallet->owner->email}"); + $this->wallet->owner->delete(); + } + } + + /** + * Send the email + * + * @param string $class Mailable class name + * @param bool $with_external Use users's external email + */ + protected function sendMail($class, $with_external = false): 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)) { + Mail::to($to)->cc($cc)->send($mail); + } + } + + /** + * 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) { + $negative_since = Carbon::now(); + $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); + } + + // Second notification + if ($type == self::THRESHOLD_REMINDER) { + return $negative_since->addDays($remind); + } + + // 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/app/Mail/Helper.php b/src/app/Mail/Helper.php --- a/src/app/Mail/Helper.php +++ b/src/app/Mail/Helper.php @@ -30,4 +30,32 @@ // HTML output return $mail->build()->render(); // @phpstan-ignore-line } + + /** + * Return user's email addresses, separately for use in To and Cc. + * + * @param \App\User $user The user + * @param bool $external Include users's external email + * + * @return array To address as the first element, Cc address(es) as the second. + */ + public static function userEmails(\App\User $user, bool $external = false): array + { + $to = $user->email; + $cc = []; + + // If user has no mailbox entitlement we should not send + // the email to his main address, but use external address, if defined + if (!$user->hasSku('mailbox')) { + $to = $user->getSetting('external_email'); + } elseif ($external) { + $ext_email = $user->getSetting('external_email'); + + if ($ext_email && $ext_email != $to) { + $cc[] = $ext_email; + } + } + + return [$to, $cc]; + } } diff --git a/src/app/Mail/NegativeBalance.php b/src/app/Mail/NegativeBalance.php --- a/src/app/Mail/NegativeBalance.php +++ b/src/app/Mail/NegativeBalance.php @@ -4,6 +4,7 @@ use App\User; use App\Utils; +use App\Wallet; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Queue\SerializesModels; @@ -13,20 +14,25 @@ use Queueable; use SerializesModels; - /** @var \App\User A user (account) that is behind with payments */ - protected $account; + /** @var \App\Wallet A wallet with a negative balance */ + protected $wallet; + + /** @var \App\User A wallet controller to whom the email is being sent */ + protected $user; /** * Create a new message instance. * - * @param \App\User $account A user (account) + * @param \App\Wallet $wallet A wallet + * @param \App\User $user An email recipient * * @return void */ - public function __construct(User $account) + public function __construct(Wallet $wallet, User $user) { - $this->account = $account; + $this->wallet = $wallet; + $this->user = $user; } /** @@ -36,8 +42,6 @@ */ public function build() { - $user = $this->account; - $subject = \trans('mail.negativebalance-subject', ['site' => \config('app.name')]); $this->view('emails.html.negative_balance') @@ -46,7 +50,7 @@ ->with([ 'site' => \config('app.name'), 'subject' => $subject, - 'username' => $user->name(true), + 'username' => $this->user->name(true), 'supportUrl' => \config('app.support_url'), 'walletUrl' => Utils::serviceUrl('/wallet'), ]); @@ -63,9 +67,10 @@ */ public static function fakeRender(string $type = 'html'): string { + $wallet = new Wallet(); $user = new User(); - $mail = new self($user); + $mail = new self($wallet, $user); return Helper::render($mail, $type); } diff --git a/src/app/Mail/NegativeBalance.php b/src/app/Mail/NegativeBalanceBeforeDelete.php copy from src/app/Mail/NegativeBalance.php copy to src/app/Mail/NegativeBalanceBeforeDelete.php --- a/src/app/Mail/NegativeBalance.php +++ b/src/app/Mail/NegativeBalanceBeforeDelete.php @@ -2,31 +2,38 @@ namespace App\Mail; +use App\Jobs\WalletCheck; use App\User; use App\Utils; +use App\Wallet; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Queue\SerializesModels; -class NegativeBalance extends Mailable +class NegativeBalanceBeforeDelete extends Mailable { use Queueable; use SerializesModels; - /** @var \App\User A user (account) that is behind with payments */ - protected $account; + /** @var \App\Wallet A wallet with a negative balance */ + protected $wallet; + + /** @var \App\User A wallet controller to whom the email is being sent */ + protected $user; /** * Create a new message instance. * - * @param \App\User $account A user (account) + * @param \App\Wallet $wallet A wallet + * @param \App\User $user An email recipient * * @return void */ - public function __construct(User $account) + public function __construct(Wallet $wallet, User $user) { - $this->account = $account; + $this->wallet = $wallet; + $this->user = $user; } /** @@ -36,19 +43,20 @@ */ public function build() { - $user = $this->account; + $threshold = WalletCheck::threshold($this->wallet, WalletCheck::THRESHOLD_DELETE); - $subject = \trans('mail.negativebalance-subject', ['site' => \config('app.name')]); + $subject = \trans('mail.negativebalancebeforedelete-subject', ['site' => \config('app.name')]); - $this->view('emails.html.negative_balance') - ->text('emails.plain.negative_balance') + $this->view('emails.html.negative_balance_before_delete') + ->text('emails.plain.negative_balance_before_delete') ->subject($subject) ->with([ 'site' => \config('app.name'), 'subject' => $subject, - 'username' => $user->name(true), + 'username' => $this->user->name(true), 'supportUrl' => \config('app.support_url'), 'walletUrl' => Utils::serviceUrl('/wallet'), + 'date' => $threshold->toDateString(), ]); return $this; @@ -63,9 +71,10 @@ */ public static function fakeRender(string $type = 'html'): string { + $wallet = new Wallet(); $user = new User(); - $mail = new self($user); + $mail = new self($wallet, $user); return Helper::render($mail, $type); } diff --git a/src/app/Mail/NegativeBalance.php b/src/app/Mail/NegativeBalanceReminder.php copy from src/app/Mail/NegativeBalance.php copy to src/app/Mail/NegativeBalanceReminder.php --- a/src/app/Mail/NegativeBalance.php +++ b/src/app/Mail/NegativeBalanceReminder.php @@ -2,31 +2,38 @@ namespace App\Mail; +use App\Jobs\WalletCheck; use App\User; use App\Utils; +use App\Wallet; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Queue\SerializesModels; -class NegativeBalance extends Mailable +class NegativeBalanceReminder extends Mailable { use Queueable; use SerializesModels; - /** @var \App\User A user (account) that is behind with payments */ - protected $account; + /** @var \App\Wallet A wallet with a negative balance */ + protected $wallet; + + /** @var \App\User A wallet controller to whom the email is being sent */ + protected $user; /** * Create a new message instance. * - * @param \App\User $account A user (account) + * @param \App\Wallet $wallet A wallet + * @param \App\User $user An email recipient * * @return void */ - public function __construct(User $account) + public function __construct(Wallet $wallet, User $user) { - $this->account = $account; + $this->wallet = $wallet; + $this->user = $user; } /** @@ -36,19 +43,20 @@ */ public function build() { - $user = $this->account; + $threshold = WalletCheck::threshold($this->wallet, WalletCheck::THRESHOLD_SUSPEND); - $subject = \trans('mail.negativebalance-subject', ['site' => \config('app.name')]); + $subject = \trans('mail.negativebalancereminder-subject', ['site' => \config('app.name')]); - $this->view('emails.html.negative_balance') - ->text('emails.plain.negative_balance') + $this->view('emails.html.negative_balance_reminder') + ->text('emails.plain.negative_balance_reminder') ->subject($subject) ->with([ 'site' => \config('app.name'), 'subject' => $subject, - 'username' => $user->name(true), + 'username' => $this->user->name(true), 'supportUrl' => \config('app.support_url'), 'walletUrl' => Utils::serviceUrl('/wallet'), + 'date' => $threshold->toDateString(), ]); return $this; @@ -63,9 +71,10 @@ */ public static function fakeRender(string $type = 'html'): string { + $wallet = new Wallet(); $user = new User(); - $mail = new self($user); + $mail = new self($wallet, $user); return Helper::render($mail, $type); } diff --git a/src/app/Mail/NegativeBalance.php b/src/app/Mail/NegativeBalanceSuspended.php copy from src/app/Mail/NegativeBalance.php copy to src/app/Mail/NegativeBalanceSuspended.php --- a/src/app/Mail/NegativeBalance.php +++ b/src/app/Mail/NegativeBalanceSuspended.php @@ -2,31 +2,38 @@ namespace App\Mail; +use App\Jobs\WalletCheck; use App\User; use App\Utils; +use App\Wallet; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Queue\SerializesModels; -class NegativeBalance extends Mailable +class NegativeBalanceSuspended extends Mailable { use Queueable; use SerializesModels; - /** @var \App\User A user (account) that is behind with payments */ - protected $account; + /** @var \App\Wallet A wallet with a negative balance */ + protected $wallet; + + /** @var \App\User A wallet controller to whom the email is being sent */ + protected $user; /** * Create a new message instance. * - * @param \App\User $account A user (account) + * @param \App\Wallet $wallet A wallet + * @param \App\User $user An email recipient * * @return void */ - public function __construct(User $account) + public function __construct(Wallet $wallet, User $user) { - $this->account = $account; + $this->wallet = $wallet; + $this->user = $user; } /** @@ -36,19 +43,20 @@ */ public function build() { - $user = $this->account; + $threshold = WalletCheck::threshold($this->wallet, WalletCheck::THRESHOLD_DELETE); - $subject = \trans('mail.negativebalance-subject', ['site' => \config('app.name')]); + $subject = \trans('mail.negativebalancesuspended-subject', ['site' => \config('app.name')]); - $this->view('emails.html.negative_balance') - ->text('emails.plain.negative_balance') + $this->view('emails.html.negative_balance_suspended') + ->text('emails.plain.negative_balance_suspended') ->subject($subject) ->with([ 'site' => \config('app.name'), 'subject' => $subject, - 'username' => $user->name(true), + 'username' => $this->user->name(true), 'supportUrl' => \config('app.support_url'), 'walletUrl' => Utils::serviceUrl('/wallet'), + 'date' => $threshold->toDateString(), ]); return $this; @@ -63,9 +71,10 @@ */ public static function fakeRender(string $type = 'html'): string { + $wallet = new Wallet(); $user = new User(); - $mail = new self($user); + $mail = new self($wallet, $user); return Helper::render($mail, $type); } diff --git a/src/app/Observers/WalletObserver.php b/src/app/Observers/WalletObserver.php --- a/src/app/Observers/WalletObserver.php +++ b/src/app/Observers/WalletObserver.php @@ -69,4 +69,41 @@ return true; } + + /** + * Handle the wallet "updated" event. + * + * @param \App\Wallet $wallet The wallet. + * + * @return void + */ + public function updated(Wallet $wallet) + { + $negative_since = $wallet->getSetting('balance_negative_since'); + + if ($wallet->balance < 0) { + if (!$negative_since) { + $now = \Carbon\Carbon::now()->toDateTimeString(); + $wallet->setSetting('balance_negative_since', $now); + } + } elseif ($negative_since) { + $wallet->setSettings([ + 'balance_negative_since' => null, + 'balance_warning_initial' => null, + 'balance_warning_reminder' => null, + 'balance_warning_suspended' => null, + 'balance_warning_before_delete' => null, + ]); + + // Unsuspend the account/domains/users + foreach ($wallet->entitlements as $entitlement) { + if ( + $entitlement->entitleable_type == \App\Domain::class + || $entitlement->entitleable_type == \App\User::class + ) { + $entitlement->entitleable->unsuspend(); + } + } + } + } } diff --git a/src/app/User.php b/src/app/User.php --- a/src/app/User.php +++ b/src/app/User.php @@ -427,6 +427,24 @@ return []; } + /** + * Check if user has an entitlement for the specified SKU. + * + * @param string $title The SKU title + * + * @return bool True if specified SKU entitlement exists + */ + public function hasSku($title): bool + { + $sku = Sku::where('title', $title)->first(); + + if (!$sku) { + return false; + } + + return $this->entitlements()->where('sku_id', $sku->id)->count() > 0; + } + /** * Returns whether this domain is active. * diff --git a/src/resources/lang/en/mail.php b/src/resources/lang/en/mail.php --- a/src/resources/lang/en/mail.php +++ b/src/resources/lang/en/mail.php @@ -17,11 +17,30 @@ 'more-info-html' => "See here for more information.", 'more-info-text' => "See :href for more information.", - 'negativebalance-subject' => ":site Payment Reminder", - 'negativebalance-body' => "It has probably skipped your attention that you are behind on paying for your :site account. " - . "Consider setting up auto-payment to avoid messages like this in the future.", + 'negativebalance-subject' => ":site Payment Required", + 'negativebalance-body' => "This is a notification to let you know that your :site account balance has run into the negative and requires your attention. " + . "Consider setting up an automatic payment to avoid messages like this in the future.", 'negativebalance-body-ext' => "Settle up to keep your account running:", + 'negativebalancereminder-subject' => ":site Payment Reminder", + 'negativebalancereminder-body' => "It has probably skipped your attention that you are behind on paying for your :site account. " + . "Consider setting up an automatic payment to avoid messages like this in the future.", + 'negativebalancereminder-body-ext' => "Settle up to keep your account running:", + 'negativebalancereminder-body-warning' => "Please, be aware that your account will be suspended " + . "if your account balance is not settled by :date.", + + 'negativebalancesuspended-subject' => ":site Account Suspended", + 'negativebalancesuspended-body' => "Your :site account has been suspended for having a negative balance for too long. " + . "Consider setting up an automatic payment to avoid messages like this in the future.", + 'negativebalancesuspended-body-ext' => "Settle up now to unsuspend your account:", + 'negativebalancesuspended-body-warning' => "Please, be aware that your account and all its data will be deleted " + . "if your account balance is not settled by :date.", + + 'negativebalancebeforedelete-subject' => ":site Final Warning", + 'negativebalancebeforedelete-body' => "This is a final reminder to settle your :site account balance. " + . "Your account and all its data will be deleted if your account balance is not settled by :date.", + 'negativebalancebeforedelete-body-ext' => "Settle up now to keep your account:", + 'passwordreset-subject' => ":site Password Reset", 'passwordreset-body1' => "Someone recently asked to change your :site password.", 'passwordreset-body2' => "If this was you, use this verification code to complete the process:", diff --git a/src/resources/views/emails/html/negative_balance_before_delete.blade.php b/src/resources/views/emails/html/negative_balance_before_delete.blade.php new file mode 100644 --- /dev/null +++ b/src/resources/views/emails/html/negative_balance_before_delete.blade.php @@ -0,0 +1,21 @@ + + + + + + +

{{ __('mail.header', ['name' => $username]) }}

+ +

{{ __('mail.negativebalancebeforedelete-body', ['site' => $site, 'date' => $date]) }}

+

{{ __('mail.negativebalancebeforedelete-body-ext', ['site' => $site]) }}

+

{{ $walletUrl }}

+ +@if ($supportUrl) +

{{ __('mail.support', ['site' => $site]) }}

+

{{ $supportUrl }}

+@endif + +

{{ __('mail.footer1') }}

+

{{ __('mail.footer2', ['site' => $site]) }}

+ + diff --git a/src/resources/views/emails/html/negative_balance_reminder.blade.php b/src/resources/views/emails/html/negative_balance_reminder.blade.php new file mode 100644 --- /dev/null +++ b/src/resources/views/emails/html/negative_balance_reminder.blade.php @@ -0,0 +1,22 @@ + + + + + + +

{{ __('mail.header', ['name' => $username]) }}

+ +

{{ __('mail.negativebalancereminder-body', ['site' => $site]) }}

+

{{ __('mail.negativebalancereminder-body-ext', ['site' => $site]) }}

+

{{ $walletUrl }}

+

{{ __('mail.negativebalancereminder-body-warning', ['site' => $site, 'date' => $date]) }}

+ +@if ($supportUrl) +

{{ __('mail.support', ['site' => $site]) }}

+

{{ $supportUrl }}

+@endif + +

{{ __('mail.footer1') }}

+

{{ __('mail.footer2', ['site' => $site]) }}

+ + diff --git a/src/resources/views/emails/html/negative_balance_suspended.blade.php b/src/resources/views/emails/html/negative_balance_suspended.blade.php new file mode 100644 --- /dev/null +++ b/src/resources/views/emails/html/negative_balance_suspended.blade.php @@ -0,0 +1,22 @@ + + + + + + +

{{ __('mail.header', ['name' => $username]) }}

+ +

{{ __('mail.negativebalancesuspended-body', ['site' => $site]) }}

+

{{ __('mail.negativebalancesuspended-body-ext', ['site' => $site]) }}

+

{{ $walletUrl }}

+

{{ __('mail.negativebalancesuspended-body-warning', ['site' => $site, 'date' => $date]) }}

+ +@if ($supportUrl) +

{{ __('mail.support', ['site' => $site]) }}

+

{{ $supportUrl }}

+@endif + +

{{ __('mail.footer1') }}

+

{{ __('mail.footer2', ['site' => $site]) }}

+ + diff --git a/src/resources/views/emails/plain/negative_balance_before_delete.blade.php b/src/resources/views/emails/plain/negative_balance_before_delete.blade.php new file mode 100644 --- /dev/null +++ b/src/resources/views/emails/plain/negative_balance_before_delete.blade.php @@ -0,0 +1,17 @@ +{!! __('mail.header', ['name' => $username]) !!} + +{!! __('mail.negativebalancebeforedelete-body', ['site' => $site, 'date' => $date]) !!} + +{!! __('mail.negativebalancebeforedelete-body-ext', ['site' => $site]) !!} + +{!! $walletUrl !!} + +@if ($supportUrl) +{!! __('mail.support', ['site' => $site]) !!} + +{!! $supportUrl !!} +@endif + +-- +{!! __('mail.footer1') !!} +{!! __('mail.footer2', ['site' => $site]) !!} diff --git a/src/resources/views/emails/plain/negative_balance_reminder.blade.php b/src/resources/views/emails/plain/negative_balance_reminder.blade.php new file mode 100644 --- /dev/null +++ b/src/resources/views/emails/plain/negative_balance_reminder.blade.php @@ -0,0 +1,19 @@ +{!! __('mail.header', ['name' => $username]) !!} + +{!! __('mail.negativebalancereminder-body', ['site' => $site]) !!} + +{!! __('mail.negativebalancereminder-body-ext', ['site' => $site]) !!} + +{!! $walletUrl !!} + +{!! __('mail.negativebalancereminder-body-warning', ['site' => $site, 'date' => $date]) !!} + +@if ($supportUrl) +{!! __('mail.support', ['site' => $site]) !!} + +{!! $supportUrl !!} +@endif + +-- +{!! __('mail.footer1') !!} +{!! __('mail.footer2', ['site' => $site]) !!} diff --git a/src/resources/views/emails/plain/negative_balance_suspended.blade.php b/src/resources/views/emails/plain/negative_balance_suspended.blade.php new file mode 100644 --- /dev/null +++ b/src/resources/views/emails/plain/negative_balance_suspended.blade.php @@ -0,0 +1,19 @@ +{!! __('mail.header', ['name' => $username]) !!} + +{!! __('mail.negativebalancesuspended-body', ['site' => $site]) !!} + +{!! __('mail.negativebalancesuspended-body-ext', ['site' => $site]) !!} + +{!! $walletUrl !!} + +{!! __('mail.negativebalancesuspended-body-warning', ['site' => $site, 'date' => $date]) !!} + +@if ($supportUrl) +{!! __('mail.support', ['site' => $site]) !!} + +{!! $supportUrl !!} +@endif + +-- +{!! __('mail.footer1') !!} +{!! __('mail.footer2', ['site' => $site]) !!} diff --git a/src/tests/Feature/Jobs/UserUpdateTest.php b/src/tests/Feature/Jobs/UserUpdateTest.php --- a/src/tests/Feature/Jobs/UserUpdateTest.php +++ b/src/tests/Feature/Jobs/UserUpdateTest.php @@ -19,6 +19,9 @@ $this->deleteTestUser('new-job-user@' . \config('app.domain')); } + /** + * {@inheritDoc} + */ public function tearDown(): void { $this->deleteTestUser('new-job-user@' . \config('app.domain')); diff --git a/src/tests/Feature/Jobs/UserVerifyTest.php b/src/tests/Feature/Jobs/UserVerifyTest.php --- a/src/tests/Feature/Jobs/UserVerifyTest.php +++ b/src/tests/Feature/Jobs/UserVerifyTest.php @@ -10,6 +10,9 @@ class UserVerifyTest extends TestCase { + /** + * {@inheritDoc} + */ public function setUp(): void { parent::setUp(); @@ -19,6 +22,9 @@ $ned->save(); } + /** + * {@inheritDoc} + */ public function tearDown(): void { $ned = $this->getTestUser('ned@kolab.org'); diff --git a/src/tests/Feature/Jobs/WalletCheckTest.php b/src/tests/Feature/Jobs/WalletCheckTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Jobs/WalletCheckTest.php @@ -0,0 +1,245 @@ +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 + $wallet->balance = 0; + $wallet->save(); + + $job = new WalletCheck($wallet); + $job->handle(); + + Mail::assertNothingSent(); + + // Balance is not 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()); + + $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 job handle, reminder notification + */ + 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 + */ + 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 + */ + 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 + */ + 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/Unit/Mail/HelperTest.php b/src/tests/Unit/Mail/HelperTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Unit/Mail/HelperTest.php @@ -0,0 +1,17 @@ +markTestIncomplete(); + } +} diff --git a/src/tests/Unit/Mail/NegativeBalanceTest.php b/src/tests/Unit/Mail/NegativeBalanceBeforeDeleteTest.php copy from src/tests/Unit/Mail/NegativeBalanceTest.php copy to src/tests/Unit/Mail/NegativeBalanceBeforeDeleteTest.php --- a/src/tests/Unit/Mail/NegativeBalanceTest.php +++ b/src/tests/Unit/Mail/NegativeBalanceBeforeDeleteTest.php @@ -2,12 +2,14 @@ namespace Tests\Unit\Mail; -use App\Mail\NegativeBalance; +use App\Jobs\WalletCheck; +use App\Mail\NegativeBalanceBeforeDelete; use App\User; +use App\Wallet; use Tests\MailInterceptTrait; use Tests\TestCase; -class NegativeBalanceTest extends TestCase +class NegativeBalanceBeforeDeleteTest extends TestCase { use MailInterceptTrait; @@ -16,13 +18,18 @@ */ public function testBuild(): void { - $user = new User(); + $user = $this->getTestUser('ned@kolab.org'); + $wallet = $user->wallets->first(); + $wallet->balance = -100; + $wallet->save(); + + $threshold = WalletCheck::threshold($wallet, WalletCheck::THRESHOLD_DELETE); \config([ 'app.support_url' => 'https://kolab.org/support', ]); - $mail = $this->fakeMail(new NegativeBalance($user)); + $mail = $this->fakeMail(new NegativeBalanceBeforeDelete($wallet, $user)); $html = $mail['html']; $plain = $mail['plain']; @@ -33,20 +40,22 @@ $supportLink = sprintf('%s', $supportUrl, $supportUrl); $appName = \config('app.name'); - $this->assertMailSubject("$appName Payment Reminder", $mail['message']); + $this->assertMailSubject("$appName Final Warning", $mail['message']); $this->assertStringStartsWith('', $html); $this->assertTrue(strpos($html, $user->name(true)) > 0); $this->assertTrue(strpos($html, $walletLink) > 0); $this->assertTrue(strpos($html, $supportLink) > 0); - $this->assertTrue(strpos($html, "behind on paying for your $appName account") > 0); + $this->assertTrue(strpos($html, "This is a final reminder to settle your $appName") > 0); + $this->assertTrue(strpos($html, $threshold->toDateString()) > 0); $this->assertTrue(strpos($html, "$appName Support") > 0); $this->assertTrue(strpos($html, "$appName Team") > 0); $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); $this->assertTrue(strpos($plain, $walletUrl) > 0); $this->assertTrue(strpos($plain, $supportUrl) > 0); - $this->assertTrue(strpos($plain, "behind on paying for your $appName account") > 0); + $this->assertTrue(strpos($plain, "This is a final reminder to settle your $appName") > 0); + $this->assertTrue(strpos($plain, $threshold->toDateString()) > 0); $this->assertTrue(strpos($plain, "$appName Support") > 0); $this->assertTrue(strpos($plain, "$appName Team") > 0); } diff --git a/src/tests/Unit/Mail/NegativeBalanceTest.php b/src/tests/Unit/Mail/NegativeBalanceReminderTest.php copy from src/tests/Unit/Mail/NegativeBalanceTest.php copy to src/tests/Unit/Mail/NegativeBalanceReminderTest.php --- a/src/tests/Unit/Mail/NegativeBalanceTest.php +++ b/src/tests/Unit/Mail/NegativeBalanceReminderTest.php @@ -2,12 +2,14 @@ namespace Tests\Unit\Mail; -use App\Mail\NegativeBalance; +use App\Jobs\WalletCheck; +use App\Mail\NegativeBalanceReminder; use App\User; +use App\Wallet; use Tests\MailInterceptTrait; use Tests\TestCase; -class NegativeBalanceTest extends TestCase +class NegativeBalanceReminderTest extends TestCase { use MailInterceptTrait; @@ -16,13 +18,18 @@ */ public function testBuild(): void { - $user = new User(); + $user = $this->getTestUser('ned@kolab.org'); + $wallet = $user->wallets->first(); + $wallet->balance = -100; + $wallet->save(); + + $threshold = WalletCheck::threshold($wallet, WalletCheck::THRESHOLD_SUSPEND); \config([ 'app.support_url' => 'https://kolab.org/support', ]); - $mail = $this->fakeMail(new NegativeBalance($user)); + $mail = $this->fakeMail(new NegativeBalanceReminder($wallet, $user)); $html = $mail['html']; $plain = $mail['plain']; @@ -39,14 +46,16 @@ $this->assertTrue(strpos($html, $user->name(true)) > 0); $this->assertTrue(strpos($html, $walletLink) > 0); $this->assertTrue(strpos($html, $supportLink) > 0); - $this->assertTrue(strpos($html, "behind on paying for your $appName account") > 0); + $this->assertTrue(strpos($html, "you are behind on paying for your $appName account") > 0); + $this->assertTrue(strpos($html, $threshold->toDateString()) > 0); $this->assertTrue(strpos($html, "$appName Support") > 0); $this->assertTrue(strpos($html, "$appName Team") > 0); $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); $this->assertTrue(strpos($plain, $walletUrl) > 0); $this->assertTrue(strpos($plain, $supportUrl) > 0); - $this->assertTrue(strpos($plain, "behind on paying for your $appName account") > 0); + $this->assertTrue(strpos($plain, "you are behind on paying for your $appName account") > 0); + $this->assertTrue(strpos($plain, $threshold->toDateString()) > 0); $this->assertTrue(strpos($plain, "$appName Support") > 0); $this->assertTrue(strpos($plain, "$appName Team") > 0); } diff --git a/src/tests/Unit/Mail/NegativeBalanceTest.php b/src/tests/Unit/Mail/NegativeBalanceSuspendedTest.php copy from src/tests/Unit/Mail/NegativeBalanceTest.php copy to src/tests/Unit/Mail/NegativeBalanceSuspendedTest.php --- a/src/tests/Unit/Mail/NegativeBalanceTest.php +++ b/src/tests/Unit/Mail/NegativeBalanceSuspendedTest.php @@ -2,12 +2,14 @@ namespace Tests\Unit\Mail; -use App\Mail\NegativeBalance; +use App\Jobs\WalletCheck; +use App\Mail\NegativeBalanceSuspended; use App\User; +use App\Wallet; use Tests\MailInterceptTrait; use Tests\TestCase; -class NegativeBalanceTest extends TestCase +class NegativeBalanceSuspendedTest extends TestCase { use MailInterceptTrait; @@ -16,13 +18,18 @@ */ public function testBuild(): void { - $user = new User(); + $user = $this->getTestUser('ned@kolab.org'); + $wallet = $user->wallets->first(); + $wallet->balance = -100; + $wallet->save(); + + $threshold = WalletCheck::threshold($wallet, WalletCheck::THRESHOLD_DELETE); \config([ 'app.support_url' => 'https://kolab.org/support', ]); - $mail = $this->fakeMail(new NegativeBalance($user)); + $mail = $this->fakeMail(new NegativeBalanceSuspended($wallet, $user)); $html = $mail['html']; $plain = $mail['plain']; @@ -33,20 +40,22 @@ $supportLink = sprintf('%s', $supportUrl, $supportUrl); $appName = \config('app.name'); - $this->assertMailSubject("$appName Payment Reminder", $mail['message']); + $this->assertMailSubject("$appName Account Suspended", $mail['message']); $this->assertStringStartsWith('', $html); $this->assertTrue(strpos($html, $user->name(true)) > 0); $this->assertTrue(strpos($html, $walletLink) > 0); $this->assertTrue(strpos($html, $supportLink) > 0); - $this->assertTrue(strpos($html, "behind on paying for your $appName account") > 0); + $this->assertTrue(strpos($html, "Your $appName account has been suspended") > 0); + $this->assertTrue(strpos($html, $threshold->toDateString()) > 0); $this->assertTrue(strpos($html, "$appName Support") > 0); $this->assertTrue(strpos($html, "$appName Team") > 0); $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); $this->assertTrue(strpos($plain, $walletUrl) > 0); $this->assertTrue(strpos($plain, $supportUrl) > 0); - $this->assertTrue(strpos($plain, "behind on paying for your $appName account") > 0); + $this->assertTrue(strpos($plain, "Your $appName account has been suspended") > 0); + $this->assertTrue(strpos($plain, $threshold->toDateString()) > 0); $this->assertTrue(strpos($plain, "$appName Support") > 0); $this->assertTrue(strpos($plain, "$appName Team") > 0); } diff --git a/src/tests/Unit/Mail/NegativeBalanceTest.php b/src/tests/Unit/Mail/NegativeBalanceTest.php --- a/src/tests/Unit/Mail/NegativeBalanceTest.php +++ b/src/tests/Unit/Mail/NegativeBalanceTest.php @@ -4,6 +4,7 @@ use App\Mail\NegativeBalance; use App\User; +use App\Wallet; use Tests\MailInterceptTrait; use Tests\TestCase; @@ -17,12 +18,13 @@ public function testBuild(): void { $user = new User(); + $wallet = new Wallet(); \config([ 'app.support_url' => 'https://kolab.org/support', ]); - $mail = $this->fakeMail(new NegativeBalance($user)); + $mail = $this->fakeMail(new NegativeBalance($wallet, $user)); $html = $mail['html']; $plain = $mail['plain']; @@ -33,20 +35,20 @@ $supportLink = sprintf('%s', $supportUrl, $supportUrl); $appName = \config('app.name'); - $this->assertMailSubject("$appName Payment Reminder", $mail['message']); + $this->assertMailSubject("$appName Payment Required", $mail['message']); $this->assertStringStartsWith('', $html); $this->assertTrue(strpos($html, $user->name(true)) > 0); $this->assertTrue(strpos($html, $walletLink) > 0); $this->assertTrue(strpos($html, $supportLink) > 0); - $this->assertTrue(strpos($html, "behind on paying for your $appName account") > 0); + $this->assertTrue(strpos($html, "your $appName account balance has run into the nega") > 0); $this->assertTrue(strpos($html, "$appName Support") > 0); $this->assertTrue(strpos($html, "$appName Team") > 0); $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); $this->assertTrue(strpos($plain, $walletUrl) > 0); $this->assertTrue(strpos($plain, $supportUrl) > 0); - $this->assertTrue(strpos($plain, "behind on paying for your $appName account") > 0); + $this->assertTrue(strpos($plain, "your $appName account balance has run into the nega") > 0); $this->assertTrue(strpos($plain, "$appName Support") > 0); $this->assertTrue(strpos($plain, "$appName Team") > 0); }